Two frontends sharing one WebSocket-backed state on the server: - Player: fullscreen poster background with a small frame (30% of the screen) that drifts diagonally and reflects off the edges. Two stacked YouTube players hand over with a 5s equal-power crossfade so there is never silence between tracks. - Queue: add by URL, drag to reorder, delete, click to jump. Shows title and length per item, highlights what is playing, and tracks remaining vs. total playtime. No API key is required — titles come from YouTube's oEmbed endpoint and durations from a hidden muted probe player that cues each new video just to read its length. The fade is driven by both requestAnimationFrame and the interval tick: a hidden or throttled tab stops serving frames entirely, which would otherwise strand a handover half-faded with two tracks audible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
64 lines
2.0 KiB
JavaScript
64 lines
2.0 KiB
JavaScript
// Keyless YouTube metadata: id parsing plus title lookup via the public oEmbed
|
|
// endpoint. Durations are not available here — they come from the probe player
|
|
// in the player frontend and arrive over the socket later.
|
|
|
|
const ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
|
|
|
/**
|
|
* Pull the 11-character video id out of anything a user might paste.
|
|
* Returns null when the input is not a recognisable YouTube video.
|
|
*/
|
|
export function parseVideoId(input) {
|
|
const raw = String(input || '').trim();
|
|
if (!raw) return null;
|
|
|
|
// A bare id pasted on its own.
|
|
if (ID_PATTERN.test(raw)) return raw;
|
|
|
|
let url;
|
|
try {
|
|
url = new URL(raw.includes('://') ? raw : `https://${raw}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const host = url.hostname.replace(/^www\./, '');
|
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
|
|
if (host === 'youtu.be') {
|
|
return ID_PATTERN.test(segments[0]) ? segments[0] : null;
|
|
}
|
|
|
|
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com' || host === 'youtube-nocookie.com') {
|
|
const v = url.searchParams.get('v');
|
|
if (v && ID_PATTERN.test(v)) return v;
|
|
|
|
// /shorts/ID, /embed/ID, /live/ID, /v/ID
|
|
if (['shorts', 'embed', 'live', 'v'].includes(segments[0])) {
|
|
return ID_PATTERN.test(segments[1]) ? segments[1] : null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the video title. Falls back to the id so a failed lookup still leaves
|
|
* a usable queue entry rather than blocking the add.
|
|
*/
|
|
export async function fetchTitle(videoId) {
|
|
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(
|
|
`https://www.youtube.com/watch?v=${videoId}`,
|
|
)}&format=json`;
|
|
|
|
try {
|
|
const res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
|
|
if (!res.ok) throw new Error(`oembed ${res.status}`);
|
|
const data = await res.json();
|
|
return data.title || videoId;
|
|
} catch (err) {
|
|
console.warn(`[youtube] title lookup failed for ${videoId}: ${err.message}`);
|
|
return videoId;
|
|
}
|
|
}
|