mirror of
https://github.com/WSA-Installer/wsa-website.git
synced 2026-07-29 11:24:39 -07:00
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:
+8
-12
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user