const { useState, useEffect, useRef } = React; const QRJAM_PREVIEW_ENDPOINT = "https://www.qrjam.nl/api/spotify-preview.php"; const QRJAM_DEFAULT_PLAYER_URL = window.QRJAM_PLAYER_BASE_URL || "https://www.qrjam.nl/webplayer/"; function qrjamShouldAutoplay() { const params = new URLSearchParams(window.location.search); return params.get("autoplay") !== "0"; } function safeDecodeQrjam(value) { const text = String(value || "").trim(); try { return decodeURIComponent(text); } catch (_) { return text; } } function cleanTrackId(id) { const decoded = safeDecodeQrjam(id); const m = decoded.match(/([A-Za-z0-9]{22})/); return m ? m[1] : ""; } function extractSpotifyTrackId(input) { if (!input) return ""; let raw = String(input).trim(); try { raw = decodeURIComponent(raw); } catch (_) {} let m = raw.match(/spotify:track:([A-Za-z0-9]{22})/i); if (m) return m[1]; m = raw.match(/open\.spotify\.com\/(?:track|trck)\/([A-Za-z0-9]{22})/i); if (m) return m[1]; try { const url = new URL(raw); const nested = url.searchParams.get("track_id") || url.searchParams.get("track") || url.searchParams.get("spotify") || url.searchParams.get("spotify_url") || url.searchParams.get("url") || url.searchParams.get("s") || url.searchParams.get("spotify_id"); if (nested) { const nestedId = extractSpotifyTrackId(nested); if (nestedId) return nestedId; } m = url.pathname.match(/\/(?:track|trck)\/([A-Za-z0-9]{22})/i); if (m) return m[1]; } catch (_) {} m = raw.match(/([A-Za-z0-9]{22})/); return m ? m[1] : ""; } function spotifyOpenUrlFor(track) { const id = extractSpotifyTrackId(track?.spotifyId || track?.spotify_id || track?.spotify_url || track?.sourceUrl || track?.source_url || ""); return id ? `https://open.spotify.com/track/${id}` : (track?.spotify_url || track?.source_url || ""); } function qrjamPlayerUrlFromScan(raw) { const text = String(raw || "").trim(); if (!text) return ""; try { const url = new URL(text); if (url.hostname.includes("qrjam.nl") && (url.pathname.startsWith("/speel") || url.pathname.startsWith("/play"))) { return text; } const id = extractSpotifyTrackId(text); if (id) { return `${QRJAM_DEFAULT_PLAYER_URL}?track_id=${encodeURIComponent(id)}&autoplay=1&_=${Date.now()}`; } return ""; } catch (_) { const id = extractSpotifyTrackId(text); return id ? `${QRJAM_DEFAULT_PLAYER_URL}?spotify=${encodeURIComponent(`https://open.spotify.com/track/${id}`)}` : ""; } } const APP_THEMES = { party: { display: '"Bricolage Grotesque", "Space Grotesk", sans-serif', bg: "#270f3d", bgImage: "radial-gradient(circle at 12% 6%, rgba(255,61,138,.34) 0, transparent 46%), radial-gradient(circle at 90% 10%, rgba(61,138,255,.26) 0, transparent 48%), radial-gradient(circle at 52% 106%, rgba(255,216,61,.20) 0, transparent 54%)", ink: "#fff", inkSoft: "rgba(255,255,255,.62)", line: "rgba(255,255,255,.16)", lineStrong: "rgba(255,255,255,.3)", soft: "rgba(255,255,255,.08)", accent: "#ff3d8a", discBg: "#2a1340", grooveA: "#3a1d57", grooveB: "#150824", mysteryTex: "radial-gradient(circle at 30% 25%, rgba(255,61,138,.28) 0, transparent 55%), radial-gradient(circle at 80% 80%, rgba(61,138,255,.22) 0, transparent 55%)", playShadow: "0 8px 26px -8px rgba(255,61,138,.7)", }, neon: { display: '"Bricolage Grotesque", "Space Grotesk", sans-serif', bg: "#0b0716", bgImage: "radial-gradient(circle at 10% 0%, rgba(255,43,209,.16) 0, transparent 44%), radial-gradient(circle at 92% 100%, rgba(0,255,229,.12) 0, transparent 46%)", ink: "#fff", inkSoft: "rgba(255,255,255,.55)", line: "rgba(255,255,255,.12)", lineStrong: "rgba(255,255,255,.24)", soft: "rgba(255,255,255,.06)", accent: "#ff2bd1", discBg: "#0a0613", grooveA: "#1c1430", grooveB: "#05030c", mysteryTex: "radial-gradient(circle at 50% 42%, rgba(255,43,209,.22) 0, transparent 60%)", playShadow: "0 0 0 1px var(--wp-accent), 0 0 30px rgba(255,43,209,.45)", }, retro: { display: '"Bricolage Grotesque", "Space Grotesk", sans-serif', bg: "#f7f1e6", bgImage: "radial-gradient(circle at 50% -10%, rgba(232,93,58,.10) 0, transparent 55%)", ink: "#1c130a", inkSoft: "#80715c", line: "rgba(28,19,10,.12)", lineStrong: "rgba(28,19,10,.22)", soft: "rgba(28,19,10,.05)", accent: "#e85d3a", discBg: "#1a1612", grooveA: "#2a241d", grooveB: "#0a0805", mysteryTex: "none", playShadow: "0 10px 24px -10px rgba(232,93,58,.65)", }, }; const THEME_LABEL = { Party: "party", Neon: "neon", Retro: "retro" }; const APP_TRACKS = (window.MOCK_PLAYLIST?.tracks) || []; function trackFromUrl() { const params = new URLSearchParams(window.location.search); const raw = params.get("track_id") || params.get("track") || params.get("spotify") || params.get("spotify_url") || params.get("url") || params.get("s") || params.get("spotify_id") || ""; const spotifyId = extractSpotifyTrackId(raw); if (spotifyId) { return { id: `sp_${spotifyId}`, spotifyId, title: "", artist: "", year: "", spotify_url: `https://open.spotify.com/track/${spotifyId}`, }; } return { id: "missing", spotifyId: "", title: "", artist: "", year: "", }; } const APlay = ({ s = 26 }) => (); const APause = ({ s = 26 }) => (); const AReplay = ({ s = 22 }) => (); const AScan = ({ s = 19 }) => (); const fmtA = (sec) => `0:${String(Math.max(0, Math.min(30, Math.floor(sec || 0)))).padStart(2, "0")}`; function AppMystery({ size }) { const disc = Math.round(size * 0.78); return (
?
Welk nummer is dit?
); } function computeArt() { const w = window.innerWidth || document.documentElement.clientWidth || 390; // Mobiel: vaste grootte op basis van schermbreedte. // Niet op window.innerHeight baseren, want die verandert tijdens scrollen // door de mobiele browserbalk. Daardoor veranderde het artwork van grootte. if (w < 860) { if (w <= 380) return 200; if (w <= 480) return 220; if (w <= 640) return 240; return 250; } // Desktop mag op hoogte reageren. const h = window.innerHeight || document.documentElement.clientHeight || 800; return Math.min(404, Math.max(300, h - 300)); } const APP_DEFAULTS = { theme: "Party", perStyleAccent: true, accent: "#ff3d8a", anim: "flip", }; function WPApp() { const [tw, setTweak] = useTweaks(APP_DEFAULTS); const theme = APP_THEMES[THEME_LABEL[tw.theme] || "party"]; const accent = tw.perStyleAccent ? theme.accent : tw.accent; const [baseTrack, setBaseTrack] = useState(trackFromUrl); const [track, setTrack] = useState(baseTrack); const [previewUrl, setPreviewUrl] = useState(baseTrack.preview_url || baseTrack.previewUrl || ""); const [previewSource, setPreviewSource] = useState(""); const [audioStatus, setAudioStatus] = useState(previewUrl ? "ready" : "loading"); const [audioMessage, setAudioMessage] = useState(previewUrl ? "" : "Preview laden…"); const [t, setT] = useState(0); const [playing, setPlaying] = useState(false); const [art, setArt] = useState(computeArt); const [scannerOpen, setScannerOpen] = useState(false); const [scannerMessage, setScannerMessage] = useState(""); const audioRef = useRef(null); const scannerRef = useRef(null); const DUR = 30; const wantsAutoplay = qrjamShouldAutoplay(); useEffect(() => { let lastWidth = window.innerWidth || document.documentElement.clientWidth || 0; const r = () => { const nextWidth = window.innerWidth || document.documentElement.clientWidth || 0; // Op mobiel veroorzaakt scrollen vaak resize-events doordat de adresbalk // inklapt/uitklapt. Als de breedte niet echt verandert, niets doen. if (nextWidth < 860 && Math.abs(nextWidth - lastWidth) < 2) { return; } lastWidth = nextWidth; setArt(computeArt()); }; window.addEventListener("resize", r); window.addEventListener("orientationchange", r); return () => { window.removeEventListener("resize", r); window.removeEventListener("orientationchange", r); }; }, []); useEffect(() => { let cancelled = false; const spotifyId = extractSpotifyTrackId(baseTrack.spotifyId || baseTrack.spotify_id || baseTrack.spotify_url || baseTrack.source_url || ""); const localPreview = baseTrack.preview_url || baseTrack.previewUrl || ""; setTrack(baseTrack); setPreviewUrl(localPreview); setPreviewSource(localPreview ? "eigen bron" : ""); setT(0); setPlaying(false); if (localPreview) { setAudioStatus("ready"); setAudioMessage(""); return () => { cancelled = true; }; } if (!spotifyId) { setAudioStatus("missing"); setAudioMessage("Deze QR bevat geen herkenbare Spotify-track."); return () => { cancelled = true; }; } setAudioStatus("loading"); setAudioMessage("Preview zoeken…"); fetch(`${QRJAM_PREVIEW_ENDPOINT}?track_id=${encodeURIComponent(spotifyId)}&_=${Date.now()}`, { headers: { "Accept": "application/json" }, cache: "no-store", }) .then(async (res) => { const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`); return body; }) .then((json) => { if (cancelled) return; if (json.track) { setTrack((current) => ({ ...current, ...json.track, spotifyId, })); } if (json.preview_url) { setPreviewUrl(json.preview_url); setPreviewSource(json.preview_source || ""); setAudioStatus("ready"); setAudioMessage(""); } else { setPreviewUrl(""); setPreviewSource(""); setAudioStatus("missing"); setAudioMessage(`Geen 30 seconden preview gevonden voor dit nummer. Track: ${spotifyId}`); } }) .catch((err) => { if (cancelled) return; setAudioStatus("error"); setAudioMessage(`Preview kon niet worden geladen: ${err.message}`); }); return () => { cancelled = true; }; }, [baseTrack]); useEffect(() => { const audio = audioRef.current; if (!audio || !previewUrl) return; audio.currentTime = 0; audio.volume = 1; setT(0); if (!wantsAutoplay) { setAudioStatus("ready"); setAudioMessage(""); return; } let cancelled = false; const tryStart = async () => { if (cancelled) return; try { await audio.play(); if (cancelled) return; setPlaying(true); setAudioStatus("playing"); setAudioMessage(""); } catch (_) { if (cancelled) return; setPlaying(false); setAudioStatus("blocked"); setAudioMessage("Automatisch starten is geblokkeerd door je browser. Tik één keer op play."); } }; // Meerdere momenten proberen: direct, na metadata en zodra audio kan spelen. tryStart(); const onCanPlay = () => tryStart(); const onLoaded = () => tryStart(); audio.addEventListener("canplay", onCanPlay, { once: true }); audio.addEventListener("loadedmetadata", onLoaded, { once: true }); const retryTimer = window.setTimeout(tryStart, 450); return () => { cancelled = true; window.clearTimeout(retryTimer); audio.removeEventListener("canplay", onCanPlay); audio.removeEventListener("loadedmetadata", onLoaded); }; }, [previewUrl, wantsAutoplay]); useEffect(() => { if (!scannerOpen) return; let stopped = false; let scanner = null; const startScanner = async () => { try { if (!window.Html5Qrcode) { setScannerMessage("QR-scanner kon niet worden geladen. Gebruik eventueel de normale camera-app."); return; } scanner = new Html5Qrcode("qrjam-next-reader", { verbose: false }); scannerRef.current = scanner; setScannerMessage("Camera starten…"); await scanner.start( { facingMode: "environment" }, { fps: 10, qrbox: (w, h) => { const size = Math.floor(Math.min(w, h) * 0.76); return { width: size, height: size }; }, }, async (decodedText) => { const nextUrl = qrjamPlayerUrlFromScan(decodedText); if (!nextUrl) { setScannerMessage("QR gevonden, maar dit is geen QRJam- of Spotify-tracklink."); return; } if (stopped) return; stopped = true; setScannerMessage("QR gevonden. Nieuwe kaart openen…"); try { await scanner.stop(); scanner.clear(); } catch (_) {} window.location.href = nextUrl; }, () => {} ); setScannerMessage("Richt je camera op de volgende QR-code."); } catch (err) { setScannerMessage(`Camera kon niet starten: ${err.message || err}`); } }; setTimeout(startScanner, 80); return () => { stopped = true; const active = scannerRef.current; scannerRef.current = null; if (active) { try { active.stop().then(() => active.clear()).catch(() => {}); } catch (_) {} } }; }, [scannerOpen]); const pct = Math.max(0, Math.min(100, (t / DUR) * 100)); const ended = t >= DUR - 0.05; const spotifyOpenUrl = spotifyOpenUrlFor(track); const playAudio = async (restart = false) => { const audio = audioRef.current; if (!audio || !previewUrl) return; if (restart || audio.currentTime >= DUR - 0.05) audio.currentTime = 0; try { await audio.play(); setPlaying(true); setAudioStatus("playing"); setAudioMessage(""); } catch (_) { setPlaying(false); setAudioStatus("blocked"); setAudioMessage("Tik nogmaals op play als je browser autoplay blokkeert."); } }; const replay = () => playAudio(true); const toggle = () => { const audio = audioRef.current; if (!audio || !previewUrl) return; if (ended || audio.ended) return playAudio(true); if (audio.paused) return playAudio(false); audio.pause(); setPlaying(false); setAudioStatus("ready"); }; const handleTimeUpdate = () => { const audio = audioRef.current; if (!audio) return; const current = Math.min(audio.currentTime || 0, DUR); setT(current); if (current >= DUR) { audio.pause(); setPlaying(false); setAudioStatus("ended"); } }; const handleEnded = () => { setT(DUR); setPlaying(false); setAudioStatus("ended"); }; const handleAudioError = () => { setPlaying(false); setAudioStatus("error"); setAudioMessage("Preview gevonden, maar deze audio kon niet worden afgespeeld. Scan eventueel de volgende kaart of open Spotify."); }; const openScanner = () => { const audio = audioRef.current; if (audio) audio.pause(); setPlaying(false); setScannerMessage(""); setScannerOpen(true); }; const closeScanner = () => { setScannerOpen(false); setScannerMessage(""); }; const statusLabel = (() => { if (audioStatus === "loading") return "Preview zoeken"; if (audioStatus === "missing") return "Geen preview"; if (audioStatus === "error") return "Fout"; if (audioStatus === "blocked") return "Tik op play"; if (audioStatus === "ended") return "Preview klaar"; return playing ? "Nu speelt" : "Preview klaar"; })(); const vars = { "--wp-bg": theme.bg, "--wp-bg-image": theme.bgImage || "none", "--wp-ink": theme.ink, "--wp-ink-soft": theme.inkSoft, "--wp-line": theme.line, "--wp-line-strong": theme.lineStrong, "--wp-soft": theme.soft, "--wp-display": theme.display, "--wp-accent": accent, "--wp-disc-bg": theme.discBg, "--wp-groove-a": theme.grooveA, "--wp-groove-b": theme.grooveB, "--wp-mystery-tex": theme.mysteryTex || "none", "--wp-play-shadow": theme.playShadow, }; return (