feat: add video pre-roll ads, navigation interstitial, and refactor ad system

- Add VideoAdOverlay (VAST parser + ad player overlay)
- Add VideoAdPlayer (YouTube IFrame API wrapper with VAST ad integration)
- Add NavigationAdOverlay (consent-style interstitial ad on page navigation)
- Add NavigationAdController (route change interceptor with sessionStorage counter)
- Replace iframe embeds with VideoAdPlayer on all 6 video pages
- Add VAST ad overlay to PIP player with play/pause on ad start/end
- Add shared YouTube types (lib/types/youtube.ts) to avoid duplicate declarations
- Add videoAds + navigationAd config sections to config.json
- Add useVideoAdsConfig + useNavigationAdConfig hooks
- Refactor AdFrame: useMemo for enabledNetworks, string key for useEffect, AdsbygoogleWindow type
- All pages build and lint clean
This commit is contained in:
MR CYBER
2026-07-18 09:34:12 -07:00
parent 44d19dd082
commit a6515c3cdb
17 changed files with 767 additions and 224 deletions
+8 -12
View File
@@ -1,22 +1,14 @@
"use client";
import { useSiteConfig, useMonetizationConfig } from "@/hooks/useRuntimeConfig";
import { useSiteConfig, useMonetizationConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import AdFrame from "@/components/ui/AdFrame";
import { usePIPConfig } from "@/hooks/useRuntimeConfig";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
import { Play, Code2, Coffee, Lightbulb } from "lucide-react";
import { useMemo } from "react";
export default function AboutPage() {
const SITE = useSiteConfig();
const MONETIZATION = useMonetizationConfig();
const pip = usePIPConfig();
const videoId = useMemo(() => {
const url = pip.videoUrl;
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=)([\w-]+)/, /(?:youtu\.be\/)([\w-]+)/, /(?:youtube\.com\/embed\/)([\w-]+)/];
for (const p of patterns) { const m = url.match(p); if (m) return m[1]; }
return null;
}, [pip.videoUrl]);
return (
<>
@@ -64,10 +56,14 @@ export default function AboutPage() {
</div>
</div>
{videoId && (
{pip.videoUrl && (
<div className="max-w-3xl mx-auto mb-12">
<div className="relative aspect-video rounded-xl overflow-hidden border border-border-primary shadow-2xl">
<iframe src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`} title="WSA Installer Demo" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="absolute inset-0 w-full h-full" />
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer Demo"
className="absolute inset-0"
/>
</div>
</div>
)}
+8 -13
View File
@@ -1,24 +1,15 @@
"use client";
import { useContentConfig, useSiteConfig } from "@/hooks/useRuntimeConfig";
import { useContentConfig, useSiteConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import Link from "next/link";
import { FileCode, Settings, HardDrive, Wrench, Shield, Code2, BookOpen, AlertTriangle, FileText } from "lucide-react";
import AdFrame from "@/components/ui/AdFrame";
import { usePIPConfig } from "@/hooks/useRuntimeConfig";
import { useMemo } from "react";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
export default function DocsPage() {
const CONTENT = useContentConfig();
const SITE = useSiteConfig();
const pip = usePIPConfig();
const videoId = useMemo(() => {
const url = pip.videoUrl;
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=)([\w-]+)/, /(?:youtu\.be\/)([\w-]+)/, /(?:youtube\.com\/embed\/)([\w-]+)/];
for (const p of patterns) { const m = url.match(p); if (m) return m[1]; }
return null;
}, [pip.videoUrl]);
return (
<>
@@ -48,12 +39,16 @@ export default function DocsPage() {
</div>
</section>
{videoId && (
{pip.videoUrl && (
<section className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="relative aspect-video rounded-xl overflow-hidden border border-border-primary shadow-2xl">
<iframe src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`} title="WSA Installer Demo" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="absolute inset-0 w-full h-full" />
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer Demo"
className="absolute inset-0"
/>
</div>
</div>
</div>
+8 -13
View File
@@ -1,26 +1,17 @@
"use client";
import { useSiteConfig, useDownloadUrls, useMonetizationConfig, useContentConfig } from "@/hooks/useRuntimeConfig";
import { useSiteConfig, useDownloadUrls, useMonetizationConfig, useContentConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import { Download, Shield, Zap, Coffee } from "lucide-react";
import Link from "next/link";
import AdFrame from "@/components/ui/AdFrame";
import { usePIPConfig } from "@/hooks/useRuntimeConfig";
import { useMemo } from "react";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
export default function DownloadsPage() {
const SITE = useSiteConfig();
const CONTENT = useContentConfig();
const downloads = useDownloadUrls();
const MONETIZATION = useMonetizationConfig();
const pip = usePIPConfig();
const videoId = useMemo(() => {
const url = pip.videoUrl;
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=)([\w-]+)/, /(?:youtu\.be\/)([\w-]+)/, /(?:youtube\.com\/embed\/)([\w-]+)/];
for (const p of patterns) { const m = url.match(p); if (m) return m[1]; }
return null;
}, [pip.videoUrl]);
return (
<>
@@ -141,12 +132,16 @@ export default function DownloadsPage() {
</div>
</section>
{videoId && (
{pip.videoUrl && (
<section className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="relative aspect-video rounded-xl overflow-hidden border border-border-primary shadow-2xl">
<iframe src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`} title="WSA Installer Demo" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="absolute inset-0 w-full h-full" />
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer Demo"
className="absolute inset-0"
/>
</div>
</div>
</div>
+8 -12
View File
@@ -1,14 +1,13 @@
"use client";
import { useContentConfig } from "@/hooks/useRuntimeConfig";
import { useContentConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import Link from "next/link";
import {
Cpu, Settings2, Zap, Store, Download, Wrench, Activity, RefreshCw,
FolderOpen, Shield, Package, Terminal, Search, HardDrive, CheckCircle2,
} from "lucide-react";
import AdFrame from "@/components/ui/AdFrame";
import { usePIPConfig } from "@/hooks/useRuntimeConfig";
import { useMemo } from "react";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
const iconMap: Record<string, React.ElementType> = {
Cpu, Settings2, Zap, Store, Download, Wrench, Activity, RefreshCw,
@@ -27,13 +26,6 @@ const categories = [
export default function FeaturesPage() {
const CONTENT = useContentConfig();
const pip = usePIPConfig();
const videoId = useMemo(() => {
const url = pip.videoUrl;
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=)([\w-]+)/, /(?:youtu\.be\/)([\w-]+)/, /(?:youtube\.com\/embed\/)([\w-]+)/];
for (const p of patterns) { const m = url.match(p); if (m) return m[1]; }
return null;
}, [pip.videoUrl]);
return (
<>
@@ -88,12 +80,16 @@ export default function FeaturesPage() {
</section>
{/* Video */}
{videoId && (
{pip.videoUrl && (
<section className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="relative aspect-video rounded-xl overflow-hidden border border-border-primary shadow-2xl">
<iframe src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`} title="WSA Installer Demo" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="absolute inset-0 w-full h-full" />
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer Demo"
className="absolute inset-0"
/>
</div>
</div>
</div>
+6 -6
View File
@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useContentConfig, useSiteConfig } from "@/hooks/useRuntimeConfig";
import { useContentConfig, useSiteConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import AdFrame from "@/components/ui/AdFrame";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
interface GalleryImage {
src: string;
@@ -17,6 +18,7 @@ const categories = ["All", "Welcome", "System", "Install", "PlayStore", "Brandin
export default function GalleryPage() {
const CONTENT = useContentConfig();
const SITE = useSiteConfig();
const pip = usePIPConfig();
const [images, setImages] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState("All");
@@ -80,12 +82,10 @@ export default function GalleryPage() {
</div>
<div className="max-w-4xl mx-auto">
<div className="relative aspect-video rounded-xl overflow-hidden border border-border-primary shadow-2xl">
<iframe
src="https://www.youtube-nocookie.com/embed/-h-YR-N5BrA"
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer - Installation Guide"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
className="absolute inset-0"
/>
</div>
</div>
+2
View File
@@ -9,6 +9,7 @@ import ScrollProgress from "@/components/ui/ScrollProgress";
import PIPVideoPlayer from "@/components/ui/PIPVideoPlayer";
import FooterAd from "@/components/ui/FooterAd";
import Footer from "@/components/sections/Footer";
import NavigationAdController from "@/components/ui/NavigationAdController";
import "./globals.css";
const outfit = Outfit({
@@ -87,6 +88,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<Footer />
</div>
<PIPVideoPlayer />
<NavigationAdController />
</ConfigProvider>
</ThemeProvider>
</body>
+8 -32
View File
@@ -3,9 +3,9 @@
import { useSiteConfig, useDownloadUrls, useContentConfig, usePIPConfig } from "@/hooks/useRuntimeConfig";
import { Download, Code2, Cpu, Settings2, Zap, Store, Shield, Terminal } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import AdFrame from "@/components/ui/AdFrame";
import Typewriter from "@/components/ui/Typewriter";
import VideoAdPlayer from "@/components/ui/VideoAdPlayer";
const featureIcons = [Cpu, Settings2, Zap, Store, Shield, Terminal];
@@ -22,28 +22,12 @@ const typewriterPhrases = [
"Zero-Trust Security",
];
function getYouTubeVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=)([\w-]+)/,
/(?:youtu\.be\/)([\w-]+)/,
/(?:youtube-nocookie\.com\/embed\/)([\w-]+)/,
/(?:youtube\.com\/embed\/)([\w-]+)/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
export default function HomePage() {
const SITE = useSiteConfig();
const CONTENT = useContentConfig();
const downloads = useDownloadUrls();
const pip = usePIPConfig();
const videoId = useMemo(() => getYouTubeVideoId(pip.videoUrl), [pip.videoUrl]);
return (
<>
{/* Hero */}
@@ -56,9 +40,9 @@ export default function HomePage() {
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-[4fr_8fr] gap-8 items-center">
<div className="text-center lg:text-left animate-slide-in-left">
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-text-primary leading-tight">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-text-primary leading-tight flex flex-wrap items-baseline gap-x-3 gap-y-1">
WSA <span className="text-accent-primary">Installer</span>
<span className="inline-flex items-center rounded-full font-mono tracking-wider bg-accent-primary/10 text-accent-primary border border-accent-primary/20 ml-4 text-[10px] font-bold px-2 py-0.5 align-middle">
<span className="inline-flex items-center rounded-full font-mono tracking-wider bg-accent-primary/10 text-accent-primary border border-accent-primary/20 text-[10px] font-bold px-2 py-0.5 align-middle shrink-0">
v{SITE.version}
</span>
</h1>
@@ -102,19 +86,11 @@ export default function HomePage() {
<div className="relative animate-slide-in-right">
<div className="relative rounded-xl overflow-hidden border border-border-primary shadow-2xl box-glow">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
{videoId ? (
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`}
title="WSA Installer Demo"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-bg-secondary text-text-muted">
No video configured
</div>
)}
<VideoAdPlayer
videoUrl={pip.videoUrl}
title="WSA Installer Demo"
className="absolute inset-0"
/>
</div>
</div>
<div className="absolute -top-2 -left-2 w-8 h-8 border-t-2 border-l-2 border-accent-primary/50 rounded-tl-lg" />
+21 -94
View File
@@ -17,6 +17,10 @@ interface AdSlide {
failed: boolean;
}
interface AdsbygoogleWindow {
adsbygoogle?: Array<Record<string, unknown>>;
}
export default function AdFrame({
slot,
className = "",
@@ -25,31 +29,31 @@ export default function AdFrame({
rotateInterval = 15000,
}: AdFrameProps) {
const { adNetworks, adPlacements } = useMonetizationConfig();
const [slides, setSlides] = useState<AdSlide[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const containerRefs = useRef<(HTMLDivElement | null)[]>([]);
const rotateTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const placement = adPlacements.find((p) => p.slot === slot);
const enabledNetworks = useMemo(
() =>
(placement?.networks
placement?.networks
? adNetworks
.filter((n) => n.enabled && placement.networks?.includes(n.id))
.sort((a, b) => a.priority - b.priority)
: adNetworks
.filter((n) => n.enabled)
.sort((a, b) => a.priority - b.priority)
),
.sort((a, b) => a.priority - b.priority),
[adNetworks, placement]
);
const enabledNetworkKey = enabledNetworks.map((n) => n.id).join(",");
const [slides, setSlides] = useState<AdSlide[]>(() =>
enabledNetworks.map((n) => ({ networkId: n.id, loaded: false, failed: false }))
);
const [activeIndex, setActiveIndex] = useState(0);
const containerRefs = useRef<(HTMLDivElement | null)[]>([]);
const rotateTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (enabledNetworks.length === 0) return;
setSlides(enabledNetworks.map((n) => ({ networkId: n.id, loaded: false, failed: false })));
setActiveIndex(0);
}, [enabledNetworkKey]);
@@ -86,80 +90,6 @@ export default function AdFrame({
};
}, [rotate, rotateInterval, slides]);
const loadAd = useCallback(
(networkIndex: number, container: HTMLDivElement) => {
const network = enabledNetworks[networkIndex];
if (!network) return;
container.innerHTML = "";
const detectAdLoad = (targetEl: Element) => {
const observer = new MutationObserver(() => {
const hasAd = targetEl.querySelector("iframe, ins.adsbygoogle, script[src]");
if (hasAd) {
markLoaded(network.id);
observer.disconnect();
}
});
observer.observe(targetEl, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
markFailed(network.id);
}, 5000);
};
if (network.type === "adsense" && network.publisherId) {
try {
const ins = document.createElement("ins");
ins.className = "adsbygoogle";
ins.style.display = "block";
ins.style.width = "100%";
if (format === "sidebar") ins.style.height = "600px";
else if (format === "native") {
ins.setAttribute("data-ad-format", "fluid");
ins.setAttribute("data-ad-layout", "in-article");
} else ins.style.height = "90px";
ins.setAttribute("data-ad-client", network.publisherId);
ins.setAttribute("data-ad-slot", slot);
container.appendChild(ins);
try {
(window as any).adsbygoogle = (window as any).adsbygoogle || [];
(window as any).adsbygoogle.push({});
} catch {}
detectAdLoad(ins);
} catch {
markFailed(network.id);
}
} else if (network.type === "monetag" && network.publisherId) {
try {
const script = document.createElement("script");
script.src = `//viblast.com/${network.publisherId}/invoke.js`;
script.async = true;
script.onload = () => markLoaded(network.id);
script.onerror = () => markFailed(network.id);
container.appendChild(script);
} catch {
markFailed(network.id);
}
} else if (network.type === "adsterra" && network.publisherId) {
try {
const script = document.createElement("script");
script.src = `//pl${network.publisherId}.adsterra.com/${network.publisherId}.js`;
script.async = true;
script.onload = () => markLoaded(network.id);
script.onerror = () => markFailed(network.id);
container.appendChild(script);
} catch {
markFailed(network.id);
}
} else {
markFailed(network.id);
}
},
[enabledNetworks, format, slot, markLoaded, markFailed]
);
useEffect(() => {
if (rotate) return;
if (enabledNetworks.length === 0) return;
@@ -220,9 +150,13 @@ export default function AdFrame({
ins.setAttribute("data-ad-slot", slot);
container.appendChild(ins);
try {
(window as any).adsbygoogle = (window as any).adsbygoogle || [];
(window as any).adsbygoogle.push({});
} catch {}
if (!ins.getAttribute("data-ad-status")) {
const win = window as unknown as AdsbygoogleWindow;
win.adsbygoogle = win.adsbygoogle || [];
win.adsbygoogle.push({});
ins.setAttribute("data-ad-status", "pushed");
}
} catch { /* ignore */ }
detectAdLoad(ins);
} catch {
clearTimeout(timeout);
@@ -292,23 +226,18 @@ export default function AdFrame({
: "min-h-[60px]";
const hasLoadedAd = slides.some((s) => s.loaded);
const loadedCount = slides.filter((s) => !s.failed).length;
const anyFailed = slides.length > 0 && slides.every((s) => s.failed);
const frameContent = (
<>
{/* Corner accents */}
<div className="absolute -top-px -left-px w-4 h-4 border-t-2 border-l-2 border-accent-primary/60 rounded-tl-lg" />
<div className="absolute -bottom-px -right-px w-4 h-4 border-b-2 border-r-2 border-accent-primary/60 rounded-br-lg" />
{/* Ad label */}
<div className="absolute top-2 right-2 z-10">
<span className="text-[9px] font-mono text-text-muted/50 uppercase tracking-wider">
Ad
</span>
</div>
{/* Ad containers */}
{rotate ? (
enabledNetworks.map((network, i) => (
<div
@@ -323,7 +252,6 @@ export default function AdFrame({
<div ref={(el) => { containerRefs.current[0] = el; }} className="w-full h-full" />
)}
{/* Placeholder when no ad loaded */}
{!hasLoadedAd && (
<div className="absolute inset-0 flex items-center justify-center z-[2]">
<div className="flex flex-col items-center gap-1.5 opacity-40">
@@ -335,8 +263,7 @@ export default function AdFrame({
</div>
)}
{/* Dot indicators */}
{rotate && loadedCount > 1 && (
{rotate && slides.length > 1 && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5">
{slides.map((slide, i) => (
<button
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { usePathname } from "next/navigation";
import NavigationAdOverlay from "./NavigationAdOverlay";
import { useNavigationAdConfig } from "@/hooks/useRuntimeConfig";
const STORAGE_KEY = "wsa-nav-count";
const FIRST_VISIT_KEY = "wsa-nav-first-visit";
export default function NavigationAdController() {
const pathname = usePathname();
const navAd = useNavigationAdConfig();
const [showAd, setShowAd] = useState(false);
const [pendingPath, setPendingPath] = useState<string | null>(null);
const lastPathRef = useRef(pathname);
useEffect(() => {
if (!navAd.enabled) return;
const isFirstVisit = !localStorage.getItem(FIRST_VISIT_KEY);
if (isFirstVisit) {
localStorage.setItem(FIRST_VISIT_KEY, "true");
lastPathRef.current = pathname;
return;
}
if (pathname !== lastPathRef.current) {
lastPathRef.current = pathname;
return;
}
}, [pathname, navAd.enabled]);
useEffect(() => {
if (!navAd.enabled) return;
const checkAndShowAd = () => {
const stored = localStorage.getItem(STORAGE_KEY);
const count = stored ? parseInt(stored, 10) : 0;
const newCount = count + 1;
localStorage.setItem(STORAGE_KEY, String(newCount));
if (newCount > 0 && newCount % navAd.showEvery === 0) {
return true;
}
return false;
};
const handler = (url: string) => {
if (url === lastPathRef.current) return;
if (checkAndShowAd()) {
setPendingPath(url);
setShowAd(true);
return false;
}
lastPathRef.current = url;
return true;
};
const originalPush = window.history.pushState;
const originalReplace = window.history.replaceState;
window.history.pushState = function (...args) {
const result = originalPush.apply(this, args);
if (args[2] && typeof args[2] === "string") {
handler(args[2]);
}
return result;
};
window.history.replaceState = function (...args) {
return originalReplace.apply(this, args);
};
return () => {
window.history.pushState = originalPush;
window.history.replaceState = originalReplace;
};
}, [navAd.enabled, navAd.showEvery]);
const handleAccept = useCallback(() => {
setShowAd(false);
if (pendingPath) {
lastPathRef.current = pendingPath;
window.history.pushState({}, "", pendingPath);
window.dispatchEvent(new PopStateEvent("popstate"));
setPendingPath(null);
}
}, [pendingPath]);
const handleSkip = useCallback(() => {
setShowAd(false);
if (pendingPath) {
lastPathRef.current = pendingPath;
window.history.pushState({}, "", pendingPath);
window.dispatchEvent(new PopStateEvent("popstate"));
setPendingPath(null);
}
}, [pendingPath]);
return (
<NavigationAdOverlay
visible={showAd}
onAccept={handleAccept}
onSkip={handleSkip}
/>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState, useEffect } from "react";
import { X, Shield, ExternalLink } from "lucide-react";
import { useNavigationAdConfig, useMonetizationConfig } from "@/hooks/useRuntimeConfig";
interface NavigationAdOverlayProps {
visible: boolean;
onAccept: () => void;
onSkip: () => void;
}
export default function NavigationAdOverlay({ visible, onAccept, onSkip }: NavigationAdOverlayProps) {
const navAd = useNavigationAdConfig();
const monetization = useMonetizationConfig();
const [countdown, setCountdown] = useState(navAd.skipAfter);
const canSkip = countdown <= 0;
useEffect(() => {
if (!visible) return;
setCountdown(navAd.skipAfter);
}, [visible, navAd.skipAfter]);
useEffect(() => {
if (!visible || countdown <= 0) return;
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [visible, countdown]);
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && canSkip) onSkip();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [visible, canSkip, onSkip]);
const adsenseId = monetization.adSensePublisherId;
if (!visible) return null;
return (
<div className="fixed inset-0 z-[99998] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="relative w-full max-w-lg mx-4 animate-slide-up opacity-0" style={{ animationFillMode: "forwards" }}>
<div className="rounded-2xl border border-border-primary bg-bg-primary/95 backdrop-blur-xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-border-primary bg-bg-secondary/50">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-accent-primary" />
<span className="text-sm font-semibold text-text-primary">Sponsored Content</span>
</div>
<button
onClick={onSkip}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-primary transition-colors"
>
{canSkip ? (
<>
<X className="w-3.5 h-3.5" />
Skip
</>
) : (
<span className="font-mono">Skip in {countdown}s</span>
)}
</button>
</div>
<div className="p-6">
<div className="relative min-h-[120px] flex items-center justify-center rounded-xl border border-border-primary bg-bg-secondary/30 overflow-hidden">
<div className="w-full p-4">
<ins
className="adsbygoogle"
style={{ display: "block" }}
data-ad-client={adsenseId}
data-ad-slot="navigation-interstitial"
data-ad-format="auto"
data-full-width-responsive="true"
/>
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
<div className="w-10 h-10 rounded-xl border border-border-primary bg-bg-tertiary flex items-center justify-center">
<span className="text-xs text-text-muted font-mono">AD</span>
</div>
<span className="text-xs text-text-muted">Advertisement</span>
</div>
</div>
<div className="mt-5 flex flex-col sm:flex-row gap-3">
<button
onClick={onAccept}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-button-primary text-bg-primary hover:bg-button-primary-hover transition-all"
>
Continue to Site
<ExternalLink className="w-3.5 h-3.5" />
</button>
{canSkip && (
<button
onClick={onSkip}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg border border-border-secondary text-text-secondary hover:text-text-primary hover:border-border-accent transition-all"
>
Skip Ad
</button>
)}
</div>
</div>
<div className="px-5 py-2 border-t border-border-primary bg-bg-secondary/30">
<p className="text-[10px] text-text-muted text-center">
This advertisement helps keep WSA Installer free and open source.
</p>
</div>
</div>
</div>
</div>
);
}
+45 -42
View File
@@ -3,55 +3,23 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ExternalLink, Volume2, VolumeX } from "lucide-react";
import { usePIPConfig } from "@/hooks/useRuntimeConfig";
import { usePIPConfig, useVideoAdsConfig } from "@/hooks/useRuntimeConfig";
import AdFrame from "@/components/ui/AdFrame";
interface YTPlayerEvent {
target: YTPlayerInstance;
data: number;
}
interface YTPlayerInstance {
playVideo: () => void;
pauseVideo: () => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
destroy: () => void;
}
interface YTStatic {
Player: new (elementId: string, config: Record<string, unknown>) => YTPlayerInstance;
PlayerState: { PLAYING: number; PAUSED: number; ENDED: number; BUFFERING: number };
}
declare global {
interface Window {
YT: YTStatic;
onYouTubeIframeAPIReady: (() => void) | undefined;
}
}
function getYouTubeVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=)([\w-]+)/,
/(?:youtu\.be\/)([\w-]+)/,
/(?:youtube-nocookie\.com\/embed\/)([\w-]+)/,
/(?:youtube\.com\/embed\/)([\w-]+)/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
import VideoAdOverlay from "@/components/ui/VideoAdOverlay";
import type { YTPlayerEvent, YTPlayerInstance } from "@/lib/types/youtube";
import "@/lib/types/youtube";
import { getYouTubeVideoId } from "@/lib/utils/youtube";
export default function PIPVideoPlayer() {
const pip = usePIPConfig();
const videoAds = useVideoAdsConfig();
const playerRef = useRef<YTPlayerInstance | null>(null);
const [isMuted, setIsMuted] = useState(true);
const [isPlayerReady, setIsPlayerReady] = useState(false);
const [videoTitle, setVideoTitle] = useState("");
const [pipPlaying, setPipPlaying] = useState(false);
const [pipCurrentTime, setPipCurrentTime] = useState(0);
const [pipDuration, setPipDuration] = useState(0);
const videoId = useMemo(() => getYouTubeVideoId(pip.videoUrl), [pip.videoUrl]);
const thumbnailUrl = videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : "";
@@ -69,7 +37,7 @@ export default function PIPVideoPlayer() {
let active = true;
const initPlayer = () => {
if (!active) return;
if (!active || !window.YT?.Player) return;
try {
playerRef.current = new window.YT.Player("pip-youtube-player", {
height: "100%",
@@ -92,6 +60,13 @@ export default function PIPVideoPlayer() {
event.target.mute();
event.target.playVideo();
setIsPlayerReady(true);
setPipDuration(event.target.getDuration());
},
onStateChange: (event: YTPlayerEvent) => {
if (!active) return;
const playing = event.target.getPlayerState() === window.YT!.PlayerState.PLAYING;
setPipPlaying(playing);
if (playing) setPipDuration(event.target.getDuration());
},
},
});
@@ -124,6 +99,17 @@ export default function PIPVideoPlayer() {
};
}, [pip.enabled, videoId]);
useEffect(() => {
if (!isPlayerReady || !playerRef.current) return;
const interval = setInterval(() => {
try {
const ct = playerRef.current?.getCurrentTime();
if (ct !== undefined) setPipCurrentTime(ct);
} catch { /* ignore */ }
}, 500);
return () => clearInterval(interval);
}, [isPlayerReady]);
const toggleMute = () => {
if (playerRef.current && typeof playerRef.current.isMuted === "function") {
if (isMuted) {
@@ -204,6 +190,23 @@ export default function PIPVideoPlayer() {
Looping
</div>
)}
{videoAds.enabled && videoAds.vastTag && (
<VideoAdOverlay
vastTag={videoAds.vastTag}
adCount={videoAds.adCount}
nonSkippable={videoAds.nonSkippable}
videoDuration={pipDuration}
currentTime={pipCurrentTime}
isPlaying={pipPlaying}
onAdStart={() => {
try { playerRef.current?.pauseVideo(); } catch { /* ignore */ }
}}
onAdEnd={() => {
try { playerRef.current?.playVideo(); } catch { /* ignore */ }
}}
/>
)}
</div>
<div className="p-4">
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { calculateAdTimestamps } from "@/lib/utils/youtube";
interface VideoAdOverlayProps {
vastTag: string;
adCount: number;
nonSkippable: boolean;
videoDuration: number;
currentTime: number;
isPlaying: boolean;
onAdStart: () => void;
onAdEnd: () => void;
}
export default function VideoAdOverlay({
vastTag,
adCount,
nonSkippable,
videoDuration,
currentTime,
isPlaying,
onAdStart,
onAdEnd,
}: VideoAdOverlayProps) {
const [active, setActive] = useState(false);
const [adVideoUrl, setAdVideoUrl] = useState<string | null>(null);
const [countdown, setCountdown] = useState(0);
const [adDuration, setAdDuration] = useState(0);
const adVideoRef = useRef<HTMLVideoElement | null>(null);
const triggeredRef = useRef<Set<number>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const adTimestamps = calculateAdTimestamps(videoDuration, adCount);
const fetchVastMediaUrl = useCallback(async (url: string): Promise<string | null> => {
try {
const res = await fetch(url);
const text = await res.text();
const parser = new DOMParser();
const xml = parser.parseFromString(text, "text/xml");
const mediaFile = xml.querySelector("MediaFile > MediaFile");
if (!mediaFile) {
const allMedia = xml.querySelectorAll("MediaFile");
for (const mf of allMedia) {
const src = mf.querySelector("StaticResource, IFrameResource, HTMLResource")?.textContent?.trim()
|| mf.getAttribute("src")
|| mf.textContent?.trim();
if (src && (src.startsWith("http") || src.startsWith("//"))) return src;
}
const inlineSrc = xml.querySelector("Linear > StaticResource")?.textContent?.trim();
if (inlineSrc) return inlineSrc;
return null;
}
const src = mediaFile.querySelector("StaticResource, IFrameResource, HTMLResource")?.textContent?.trim()
|| mediaFile.getAttribute("src")
|| mediaFile.textContent?.trim();
return src && (src.startsWith("http") || src.startsWith("//")) ? src : null;
} catch {
return null;
}
}, []);
useEffect(() => {
if (!isPlaying || active || adTimestamps.length === 0 || !vastTag) return;
for (const ts of adTimestamps) {
if (currentTime >= ts && currentTime < ts + 2 && !triggeredRef.current.has(ts)) {
triggeredRef.current.add(ts);
setActive(true);
onAdStart();
fetchVastMediaUrl(vastTag).then((url) => {
if (url) setAdVideoUrl(url);
});
break;
}
}
}, [currentTime, isPlaying, active, adTimestamps, vastTag, onAdStart, fetchVastMediaUrl]);
useEffect(() => {
if (!active || !adVideoUrl || !adVideoRef.current) return;
const video = adVideoRef.current;
video.load();
video.play().catch(() => {});
}, [active, adVideoUrl]);
useEffect(() => {
if (!active) return;
const interval = setInterval(() => {
if (adVideoRef.current) {
const remaining = Math.ceil(adVideoRef.current.duration - adVideoRef.current.currentTime);
setCountdown(remaining > 0 ? remaining : 0);
setAdDuration(adVideoRef.current.duration || 0);
}
}, 250);
return () => clearInterval(interval);
}, [active]);
const handleAdEnded = useCallback(() => {
setActive(false);
setAdVideoUrl(null);
setCountdown(0);
onAdEnd();
}, [onAdEnd]);
const handleSkip = useCallback(() => {
if (nonSkippable && countdown > 0) return;
handleAdEnded();
}, [nonSkippable, countdown, handleAdEnded]);
if (!active) return null;
const progress = adDuration > 0 ? ((adDuration - countdown) / adDuration) * 100 : 0;
const canSkip = !nonSkippable || countdown <= 0;
return (
<div ref={containerRef} className="absolute inset-0 z-50 bg-black flex items-center justify-center">
{adVideoUrl ? (
<video
ref={adVideoRef}
className="w-full h-full object-contain"
onEnded={handleAdEnded}
playsInline
muted
controls={false}
>
<source src={adVideoUrl} type="video/mp4" />
</video>
) : (
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-text-muted font-mono">Loading ad...</span>
</div>
)}
<div className="absolute top-3 right-3 bg-black/70 rounded-lg px-3 py-1.5 flex items-center gap-2">
<span className="text-xs text-text-muted font-mono">
Ad {nonSkippable ? "" : `remaining: `}{countdown}s
</span>
{nonSkippable && countdown > 0 && (
<div className="w-16 h-1 bg-white/20 rounded-full overflow-hidden">
<div
className="h-full bg-accent-primary transition-all duration-250"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
{canSkip && (
<button
onClick={handleSkip}
className="absolute bottom-4 right-4 bg-black/70 hover:bg-black/90 text-white text-xs font-mono px-4 py-2 rounded-lg border border-white/20 transition-colors"
>
Close Ad
</button>
)}
<div className="absolute bottom-3 left-3 bg-black/70 rounded-lg px-3 py-1.5">
<span className="text-[10px] text-text-muted font-mono uppercase tracking-wider">Advertisement</span>
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState, useEffect, useRef, useMemo, useId } from "react";
import { getYouTubeVideoId } from "@/lib/utils/youtube";
import VideoAdOverlay from "./VideoAdOverlay";
import { useVideoAdsConfig } from "@/hooks/useRuntimeConfig";
import type { YTPlayerEvent, YTPlayerInstance } from "@/lib/types/youtube";
import "@/lib/types/youtube";
interface VideoAdPlayerProps {
videoUrl: string;
title?: string;
className?: string;
}
let ytApiLoading = false;
let ytApiLoaded = false;
function loadYtApi(): Promise<void> {
if (ytApiLoaded) return Promise.resolve();
if (ytApiLoading) {
return new Promise((resolve) => {
const check = setInterval(() => {
if (ytApiLoaded) { clearInterval(check); resolve(); }
}, 100);
});
}
ytApiLoading = true;
return new Promise((resolve) => {
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
document.body.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
ytApiLoaded = true;
resolve();
};
});
}
export default function VideoAdPlayer({ videoUrl, className = "" }: VideoAdPlayerProps) {
const videoId = useMemo(() => getYouTubeVideoId(videoUrl), [videoUrl]);
const videoAds = useVideoAdsConfig();
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<YTPlayerInstance | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [apiReady, setApiReady] = useState(false);
const rawId = useId();
const uid = useMemo(() => `yt-vap-${rawId.replace(/:/g, "")}`, [rawId]);
useEffect(() => {
if (!videoId) return;
let active = true;
loadYtApi().then(() => {
if (!active || !window.YT?.Player) return;
try {
playerRef.current = new window.YT.Player(uid, {
height: "100%",
width: "100%",
videoId,
playerVars: {
autoplay: 0,
controls: 1,
modestbranding: 1,
rel: 0,
},
events: {
onReady: (e: YTPlayerEvent) => {
if (!active) return;
setDuration(e.target.getDuration());
setApiReady(true);
},
onStateChange: (e: YTPlayerEvent) => {
if (!active) return;
const state = e.target.getPlayerState();
const playing = state === window.YT!.PlayerState.PLAYING;
setIsPlaying(playing);
if (playing) setDuration(e.target.getDuration());
},
},
});
} catch { /* silent */ }
});
return () => {
active = false;
if (playerRef.current && typeof playerRef.current.destroy === "function") {
playerRef.current.destroy();
playerRef.current = null;
}
setApiReady(false);
};
}, [videoId, uid]);
useEffect(() => {
if (!apiReady || !playerRef.current) return;
const interval = setInterval(() => {
try {
const ct = playerRef.current?.getCurrentTime();
if (ct !== undefined) setCurrentTime(ct);
} catch { /* ignore */ }
}, 500);
return () => clearInterval(interval);
}, [apiReady]);
const handleAdStart = () => {
try { playerRef.current?.pauseVideo(); } catch { /* ignore */ }
};
const handleAdEnd = () => {
try { playerRef.current?.playVideo(); } catch { /* ignore */ }
};
if (!videoId) {
return (
<div className={`relative flex items-center justify-center bg-bg-secondary text-text-muted ${className}`}>
No video configured
</div>
);
}
return (
<div ref={containerRef} className={`relative w-full h-full ${className}`}>
<div id={uid} className="absolute inset-0 w-full h-full" />
{videoAds.enabled && videoAds.vastTag && (
<VideoAdOverlay
vastTag={videoAds.vastTag}
adCount={videoAds.adCount}
nonSkippable={videoAds.nonSkippable}
videoDuration={duration}
currentTime={currentTime}
isPlaying={isPlaying}
onAdStart={handleAdStart}
onAdEnd={handleAdEnd}
/>
)}
</div>
);
}
+13
View File
@@ -218,6 +218,19 @@
{ "name": "NordVPN", "url": "https://nordvpn.com/", "description": "Secure your WSA browsing" },
{ "name": "DigitalOcean", "url": "https://digitalocean.com/", "description": "Cloud hosting for WSA development" }
]
},
"videoAds": {
"enabled": true,
"vastTag": "",
"adCount": 4,
"nonSkippable": true
},
"navigationAd": {
"enabled": true,
"network": "adsense",
"showEvery": 3,
"duration": 5,
"skipAfter": 3
}
},
"pipVideo": {
+51
View File
@@ -132,3 +132,54 @@ export function usePIPConfig() {
ctaUrl: c?.ctaUrl ?? "",
};
}
export interface VideoAdsConfig {
enabled: boolean;
vastTag: string;
adCount: number;
nonSkippable: boolean;
}
export interface NavigationAdConfig {
enabled: boolean;
network: string;
showEvery: number;
duration: number;
skipAfter: number;
}
interface MonetizationConfig {
provider: string;
adSensePublisherId: string;
adNetworks: AdNetwork[];
adPlacements: AdPlacement[];
buyMeACoffee: { enabled: boolean; url: string; username: string };
gitHubSponsors: { enabled: boolean; url: string };
koFi: { enabled: boolean; url: string };
affiliateLinks: { enabled: boolean; links: { name: string; url: string; description: string }[] };
videoAds: { enabled: boolean; vastTag: string; adCount: number; nonSkippable: boolean };
navigationAd: { enabled: boolean; network: string; showEvery: number; duration: number; skipAfter: number };
}
export function useVideoAdsConfig(): VideoAdsConfig {
const c = useConfig().monetization as MonetizationConfig;
const v = c.videoAds;
return {
enabled: v?.enabled ?? false,
vastTag: v?.vastTag ?? "",
adCount: v?.adCount ?? 4,
nonSkippable: v?.nonSkippable ?? true,
};
}
export function useNavigationAdConfig(): NavigationAdConfig {
const c = useConfig().monetization as MonetizationConfig;
const n = c.navigationAd;
return {
enabled: n?.enabled ?? false,
network: n?.network ?? "adsense",
showEvery: n?.showEvery ?? 3,
duration: n?.duration ?? 5,
skipAfter: n?.skipAfter ?? 3,
};
}
+30
View File
@@ -0,0 +1,30 @@
export interface YTPlayerEvent {
target: YTPlayerInstance;
data: number;
}
export interface YTPlayerInstance {
playVideo: () => void;
pauseVideo: () => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
getCurrentTime: () => number;
getDuration: () => number;
getPlayerState: () => number;
destroy: () => void;
}
export interface YTStatic {
Player: new (elementId: string, config: Record<string, unknown>) => YTPlayerInstance;
PlayerState: { PLAYING: number; PAUSED: number; ENDED: number; BUFFERING: number };
}
declare global {
interface Window {
YT?: YTStatic;
onYouTubeIframeAPIReady?: (() => void) | undefined;
}
}
export {};
+28
View File
@@ -0,0 +1,28 @@
const YOUTUBE_PATTERNS = [
/(?:youtube\.com\/watch\?v=)([\w-]+)/,
/(?:youtu\.be\/)([\w-]+)/,
/(?:youtube-nocookie\.com\/embed\/)([\w-]+)/,
/(?:youtube\.com\/embed\/)([\w-]+)/,
];
export function getYouTubeVideoId(url: string): string | null {
for (const p of YOUTUBE_PATTERNS) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
export function getYouTubeThumbnail(videoId: string): string {
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
export function calculateAdTimestamps(videoDuration: number, adCount: number): number[] {
if (adCount <= 0 || videoDuration <= 0) return [];
const interval = videoDuration / (adCount + 1);
const timestamps: number[] = [];
for (let i = 1; i <= adCount; i++) {
timestamps.push(Math.round(interval * i));
}
return timestamps;
}