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 (
);
}
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 (
QRJam Webplayer
Welk nummer is dit?
{audioMessage ?
{audioMessage}
: null}
Je hoort hier previews van 30 seconden. Wil je nummers volledig beluisteren? Dan heb je onze iOS app nodig.
{(audioStatus === "missing" || audioStatus === "error") && spotifyOpenUrl ? (
Open in Spotify
) : null}
{scannerOpen ? (
Volgende kaart
Scan de volgende QR
{scannerMessage || "Camera voorbereiden…"}
) : null}
setTweak("theme", v)} />
setTweak("perStyleAccent", v)} />
setTweak({ accent: v, perStyleAccent: false })} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render();