Add party player: YouTube queue with crossfading playback
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
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Party Player</title>
|
||||
<link rel="stylesheet" href="/player/player.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="backgrounds"></div>
|
||||
|
||||
<!-- #frameMover owns the motion transform and nothing else, so future
|
||||
animations can nest their own transform layers without conflict. -->
|
||||
<div id="frameMover">
|
||||
<div id="frameSkin">
|
||||
<div class="deck" data-deck="a"><div id="deck-a"></div></div>
|
||||
<div class="deck" data-deck="b"><div id="deck-b"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offscreen, muted, 1x1: cues queued videos purely to read their length. -->
|
||||
<div id="probe"><div id="probe-player"></div></div>
|
||||
|
||||
<div id="gate">
|
||||
<div class="gate-inner">
|
||||
<h1>Party Player</h1>
|
||||
<p id="gate-hint">Loading queue…</p>
|
||||
<button id="start" type="button" disabled>Start</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" hidden>reconnecting…</div>
|
||||
|
||||
<script type="module" src="/player/player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
// Frame motion. Everything here writes to #frameMover's transform and nothing
|
||||
// else, so additional animation layers can be nested inside without conflict.
|
||||
|
||||
import { FRAME_SCALE, BOUNCE_SPEED } from '/config.js';
|
||||
|
||||
const ASPECT = 9 / 16;
|
||||
|
||||
/**
|
||||
* Frame size for a viewport: FRAME_SCALE of the width, but never taller than
|
||||
* FRAME_SCALE of the height, so a wide screen does not get a letterbox strip.
|
||||
*/
|
||||
export function frameSize(viewportWidth, viewportHeight) {
|
||||
let width = viewportWidth * FRAME_SCALE;
|
||||
let height = width * ASPECT;
|
||||
|
||||
const maxHeight = viewportHeight * FRAME_SCALE;
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height / ASPECT;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagonal drift with reflection off the viewport edges. Pure state + step, so
|
||||
* the physics can be exercised without a browser and without waiting on rAF.
|
||||
*/
|
||||
export function createBouncer({ x = 0, y = 0 } = {}) {
|
||||
const bouncer = {
|
||||
x,
|
||||
y,
|
||||
// A shallow diagonal reads better than 45°, and never retraces its path.
|
||||
vx: BOUNCE_SPEED,
|
||||
vy: BOUNCE_SPEED * 0.62,
|
||||
|
||||
step(dt, bounds) {
|
||||
const maxX = Math.max(0, bounds.width);
|
||||
const maxY = Math.max(0, bounds.height);
|
||||
|
||||
bouncer.x += bouncer.vx * dt;
|
||||
bouncer.y += bouncer.vy * dt;
|
||||
|
||||
// Reflect, mirroring the overshoot back inside the viewport.
|
||||
if (bouncer.x <= 0) {
|
||||
bouncer.x = Math.min(-bouncer.x, maxX);
|
||||
bouncer.vx = Math.abs(bouncer.vx);
|
||||
} else if (bouncer.x >= maxX) {
|
||||
bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0);
|
||||
bouncer.vx = -Math.abs(bouncer.vx);
|
||||
}
|
||||
|
||||
if (bouncer.y <= 0) {
|
||||
bouncer.y = Math.min(-bouncer.y, maxY);
|
||||
bouncer.vy = Math.abs(bouncer.vy);
|
||||
} else if (bouncer.y >= maxY) {
|
||||
bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0);
|
||||
bouncer.vy = -Math.abs(bouncer.vy);
|
||||
}
|
||||
|
||||
return bouncer;
|
||||
},
|
||||
};
|
||||
|
||||
return bouncer;
|
||||
}
|
||||
|
||||
export function startBounce(element) {
|
||||
let size = frameSize(window.innerWidth, window.innerHeight);
|
||||
const bouncer = createBouncer({
|
||||
x: (window.innerWidth - size.width) / 2,
|
||||
y: (window.innerHeight - size.height) / 2,
|
||||
});
|
||||
|
||||
function bounds() {
|
||||
return {
|
||||
width: window.innerWidth - size.width,
|
||||
height: window.innerHeight - size.height,
|
||||
};
|
||||
}
|
||||
|
||||
function measure() {
|
||||
size = frameSize(window.innerWidth, window.innerHeight);
|
||||
element.style.width = `${size.width}px`;
|
||||
element.style.height = `${size.height}px`;
|
||||
|
||||
const limit = bounds();
|
||||
bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width));
|
||||
bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height));
|
||||
}
|
||||
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
let last = performance.now();
|
||||
|
||||
function frame(now) {
|
||||
// Clamped so a stalled tab does not teleport the frame on resume.
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
|
||||
bouncer.step(dt, bounds());
|
||||
element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
return bouncer;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
:root {
|
||||
--accent: #ff4fc3;
|
||||
--ink: #14060f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* --- background ------------------------------------------------------- */
|
||||
|
||||
#backgrounds {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#backgrounds img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 900ms ease;
|
||||
/* Knock the poster back so the video frame stays the focal point — the
|
||||
posters are pale and read far brighter on a projector than on a desk. */
|
||||
filter: brightness(0.45) saturate(1.15);
|
||||
}
|
||||
|
||||
#backgrounds img.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --- the moving frame -------------------------------------------------- */
|
||||
|
||||
#frameMover {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
#frameSkin {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(255, 79, 195, 0.55),
|
||||
0 24px 70px rgba(0, 0, 0, 0.7);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.deck {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
/* Opacity is driven frame-by-frame during a crossfade, so no CSS transition
|
||||
here — it would fight the JS ramp. */
|
||||
}
|
||||
|
||||
.deck iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
/* The iframe would otherwise swallow nothing useful, but keeping it inert
|
||||
avoids stray clicks pausing playback. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#probe {
|
||||
position: fixed;
|
||||
left: -400px;
|
||||
top: -400px;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- start gate -------------------------------------------------------- */
|
||||
|
||||
#gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(20, 6, 15, 0.92);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
transition: opacity 400ms ease;
|
||||
}
|
||||
|
||||
#gate.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-inner {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gate-inner h1 {
|
||||
font-size: clamp(2rem, 7vw, 5rem);
|
||||
margin: 0 0 0.3em;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.gate-inner p {
|
||||
margin: 0 0 2em;
|
||||
opacity: 0.7;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
#start {
|
||||
font: inherit;
|
||||
font-size: 1.3rem;
|
||||
padding: 0.7em 3em;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#start:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#status {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { connect } from '/shared/socket.js';
|
||||
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js';
|
||||
import { createPlayer, loadApi, STATE } from './yt.js';
|
||||
import { startBounce } from './motion.js';
|
||||
|
||||
const TICK_MS = 250;
|
||||
|
||||
let state = { items: [], currentId: null, background: 'pink' };
|
||||
let decks = [];
|
||||
let active = 0;
|
||||
let transition = null; // { until, duration, from, to } while crossfading
|
||||
let started = false;
|
||||
|
||||
const socket = connect({ onMessage, onStatus });
|
||||
|
||||
// --- backgrounds --------------------------------------------------------
|
||||
|
||||
const backgroundLayer = document.getElementById('backgrounds');
|
||||
const backgroundImages = new Map();
|
||||
|
||||
for (const [key, { src }] of Object.entries(BACKGROUNDS)) {
|
||||
const img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.alt = '';
|
||||
backgroundLayer.appendChild(img);
|
||||
backgroundImages.set(key, img);
|
||||
}
|
||||
|
||||
function applyBackground(key) {
|
||||
for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key);
|
||||
}
|
||||
|
||||
applyBackground(state.background);
|
||||
|
||||
// --- start gate ---------------------------------------------------------
|
||||
|
||||
const gate = document.getElementById('gate');
|
||||
const startButton = document.getElementById('start');
|
||||
const gateHint = document.getElementById('gate-hint');
|
||||
|
||||
startButton.addEventListener('click', start);
|
||||
gate.addEventListener('click', (event) => {
|
||||
if (event.target === gate && !startButton.disabled) start();
|
||||
});
|
||||
|
||||
function updateGate() {
|
||||
if (started) return;
|
||||
const ready = state.items.length > 0;
|
||||
startButton.disabled = !ready;
|
||||
gateHint.textContent = ready
|
||||
? `${state.items.length} video${state.items.length === 1 ? '' : 's'} queued — click to start the music`
|
||||
: 'Add videos from the queue page to begin.';
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
gate.classList.add('hidden');
|
||||
|
||||
startBounce(document.getElementById('frameMover'));
|
||||
|
||||
decks = await Promise.all([
|
||||
makeDeck('deck-a', 0),
|
||||
makeDeck('deck-b', 1),
|
||||
]);
|
||||
|
||||
const current = currentItem();
|
||||
if (current) playOn(active, current, { volume: 100, opacity: 1 });
|
||||
|
||||
setInterval(tick, TICK_MS);
|
||||
setInterval(reportProgress, 1000);
|
||||
}
|
||||
|
||||
// --- decks --------------------------------------------------------------
|
||||
|
||||
async function makeDeck(elementId, index) {
|
||||
const element = document.querySelector(`.deck[data-deck="${index === 0 ? 'a' : 'b'}"]`);
|
||||
const deck = {
|
||||
element,
|
||||
itemId: null,
|
||||
player: null,
|
||||
setOpacity(value) {
|
||||
element.style.opacity = String(value);
|
||||
},
|
||||
};
|
||||
|
||||
deck.player = await createPlayer(elementId, {
|
||||
onStateChange: (code) => onDeckState(index, code),
|
||||
});
|
||||
deck.player.setVolume(0);
|
||||
deck.setOpacity(0);
|
||||
return deck;
|
||||
}
|
||||
|
||||
function playOn(index, item, { volume, opacity }) {
|
||||
const deck = decks[index];
|
||||
deck.itemId = item.id;
|
||||
deck.player.loadVideoById(item.videoId);
|
||||
deck.player.setVolume(volume);
|
||||
deck.player.unMute();
|
||||
deck.setOpacity(opacity);
|
||||
}
|
||||
|
||||
function onDeckState(index, code) {
|
||||
if (index !== active || transition) return;
|
||||
|
||||
// Safety net: a track that ended without our tick catching it (very short
|
||||
// clips, or a stalled tab) still has to hand over.
|
||||
if (code === STATE.ENDED) {
|
||||
const next = nextItem();
|
||||
if (!next || next.id === decks[active].itemId) {
|
||||
decks[active].player.seekTo(0, true);
|
||||
decks[active].player.playVideo();
|
||||
} else {
|
||||
beginCrossfade(next, HARD_CUT_SECONDS, { advance: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- playback loop ------------------------------------------------------
|
||||
|
||||
function tick() {
|
||||
if (!decks.length) return;
|
||||
|
||||
if (transition) {
|
||||
stepTransition();
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = decks[active];
|
||||
|
||||
// Queue emptied out from under us — stop rather than loop the video the
|
||||
// user just deleted.
|
||||
if (state.items.length === 0) {
|
||||
if (deck.itemId) {
|
||||
deck.player.stopVideo();
|
||||
deck.setOpacity(0);
|
||||
deck.itemId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing playing but the queue has items again (all deleted, then re-added).
|
||||
if (!deck.itemId) {
|
||||
const resume = currentItem();
|
||||
if (resume) playOn(active, resume, { volume: 100, opacity: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
// The server pointer moved somewhere we are not playing — a jump, or the
|
||||
// current item was deleted. Fade there quickly instead of waiting.
|
||||
if (state.currentId && deck.itemId !== state.currentId) {
|
||||
const target = state.items.find((item) => item.id === state.currentId);
|
||||
if (target) {
|
||||
beginCrossfade(target, HARD_CUT_SECONDS, { advance: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = deck.player.getDuration?.() ?? 0;
|
||||
const currentTime = deck.player.getCurrentTime?.() ?? 0;
|
||||
if (!duration) return;
|
||||
|
||||
if (duration - currentTime <= OVERLAP_SECONDS) {
|
||||
const next = nextItem();
|
||||
// A single-item queue loops instead of crossfading with itself.
|
||||
if (!next || next.id === deck.itemId) return;
|
||||
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
|
||||
}
|
||||
}
|
||||
|
||||
function beginCrossfade(item, seconds, { advance }) {
|
||||
const from = active;
|
||||
const to = 1 - active;
|
||||
|
||||
playOn(to, item, { volume: 0, opacity: 0 });
|
||||
decks[to].player.playVideo();
|
||||
|
||||
transition = {
|
||||
startedAt: performance.now(),
|
||||
duration: seconds * 1000,
|
||||
from,
|
||||
to,
|
||||
advance,
|
||||
outgoingId: decks[from].itemId,
|
||||
};
|
||||
|
||||
rampTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the fade for the current point in time. Driven by animation frames for
|
||||
* smoothness, and also by the tick — a hidden or throttled tab stops serving
|
||||
* rAF entirely, and a half-faded handover would leave two tracks audible.
|
||||
*/
|
||||
function stepTransition() {
|
||||
if (!transition) return false;
|
||||
|
||||
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
||||
const outgoing = decks[transition.from];
|
||||
const incoming = decks[transition.to];
|
||||
|
||||
// Equal-power curve: the combined loudness stays flat through the middle
|
||||
// of the mix instead of dipping.
|
||||
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
|
||||
incoming.player.setVolume(Math.round(Math.sin((progress * Math.PI) / 2) * 100));
|
||||
outgoing.setOpacity(1 - progress);
|
||||
incoming.setOpacity(progress);
|
||||
|
||||
if (progress < 1) return true;
|
||||
finishTransition();
|
||||
return false;
|
||||
}
|
||||
|
||||
function rampTransition() {
|
||||
if (stepTransition()) requestAnimationFrame(rampTransition);
|
||||
}
|
||||
|
||||
function finishTransition() {
|
||||
if (!transition) return;
|
||||
const { from, to, advance, outgoingId } = transition;
|
||||
transition = null;
|
||||
|
||||
decks[from].player.stopVideo();
|
||||
decks[from].player.setVolume(0);
|
||||
decks[from].setOpacity(0);
|
||||
decks[from].itemId = null;
|
||||
|
||||
decks[to].player.setVolume(100);
|
||||
decks[to].setOpacity(1);
|
||||
active = to;
|
||||
|
||||
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
|
||||
}
|
||||
|
||||
function reportProgress() {
|
||||
const deck = decks[active];
|
||||
if (!deck?.itemId || transition) return;
|
||||
const currentTime = deck.player.getCurrentTime?.() ?? 0;
|
||||
socket.send({ type: 'progress', id: deck.itemId, currentTime });
|
||||
}
|
||||
|
||||
// --- queue helpers ------------------------------------------------------
|
||||
|
||||
function currentItem() {
|
||||
return state.items.find((item) => item.id === state.currentId) || state.items[0] || null;
|
||||
}
|
||||
|
||||
function nextItem() {
|
||||
if (state.items.length === 0) return null;
|
||||
const index = state.items.findIndex((item) => item.id === decks[active]?.itemId);
|
||||
if (index === -1) return currentItem();
|
||||
return state.items[(index + 1) % state.items.length];
|
||||
}
|
||||
|
||||
// --- duration probe -----------------------------------------------------
|
||||
// Cues (never plays) each unmeasured video in a hidden muted player purely to
|
||||
// read getDuration(), which is the only keyless way to learn a video's length.
|
||||
|
||||
let probe = null;
|
||||
let probing = null;
|
||||
|
||||
async function initProbe() {
|
||||
await loadApi();
|
||||
probe = await createPlayer('probe-player', { onStateChange: onProbeState });
|
||||
probe.mute();
|
||||
probe.setVolume(0);
|
||||
pumpProbe();
|
||||
}
|
||||
|
||||
function onProbeState(code) {
|
||||
if (!probing || (code !== STATE.CUED && code !== STATE.PLAYING)) return;
|
||||
|
||||
const seconds = probe.getDuration();
|
||||
if (seconds > 0) {
|
||||
socket.send({ type: 'duration', id: probing, seconds });
|
||||
probing = null;
|
||||
pumpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
function pumpProbe() {
|
||||
if (!probe || probing) return;
|
||||
const pending = state.items.find((item) => item.duration === null);
|
||||
if (!pending) return;
|
||||
|
||||
probing = pending.id;
|
||||
probe.cueVideoById(pending.videoId);
|
||||
|
||||
// If the video is unavailable the CUED event never carries a length; move on
|
||||
// rather than stalling every later item behind it.
|
||||
const stuckOn = pending.id;
|
||||
setTimeout(() => {
|
||||
if (probing === stuckOn) {
|
||||
probing = null;
|
||||
pumpProbe();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
initProbe();
|
||||
|
||||
// --- socket -------------------------------------------------------------
|
||||
|
||||
function onMessage(msg) {
|
||||
if (msg.type !== 'state') return;
|
||||
|
||||
state = msg.state;
|
||||
applyBackground(state.background);
|
||||
updateGate();
|
||||
pumpProbe();
|
||||
}
|
||||
|
||||
function onStatus(status) {
|
||||
document.getElementById('status').hidden = status === 'online';
|
||||
}
|
||||
|
||||
updateGate();
|
||||
|
||||
// Debug helper used when verifying the crossfade without waiting out a track.
|
||||
window.__player = {
|
||||
seekToEnd(secondsBefore = OVERLAP_SECONDS + 2) {
|
||||
const deck = decks[active];
|
||||
const duration = deck?.player.getDuration?.() ?? 0;
|
||||
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
|
||||
},
|
||||
get state() {
|
||||
return { state, active, transition, decks: decks.map((d) => d.itemId) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// Thin wrapper around the YouTube IFrame Player API.
|
||||
|
||||
let apiReady = null;
|
||||
|
||||
/** Load the IFrame API once; resolves when window.YT is usable. */
|
||||
export function loadApi() {
|
||||
if (apiReady) return apiReady;
|
||||
|
||||
apiReady = new Promise((resolve) => {
|
||||
if (window.YT?.Player) return resolve(window.YT);
|
||||
window.onYouTubeIframeAPIReady = () => resolve(window.YT);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return apiReady;
|
||||
}
|
||||
|
||||
const PLAYER_VARS = {
|
||||
controls: 0,
|
||||
disablekb: 1,
|
||||
modestbranding: 1,
|
||||
rel: 0,
|
||||
playsinline: 1,
|
||||
iv_load_policy: 3,
|
||||
fs: 0,
|
||||
cc_load_policy: 0, // no burned-in captions over the visuals
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a player in the given element id. Resolves once the player is ready.
|
||||
* `onStateChange` receives the raw YT state code.
|
||||
*/
|
||||
export async function createPlayer(elementId, { onStateChange } = {}) {
|
||||
const YT = await loadApi();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const player = new YT.Player(elementId, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
playerVars: { ...PLAYER_VARS, origin: location.origin },
|
||||
events: {
|
||||
onReady: () => resolve(player),
|
||||
onStateChange: (event) => onStateChange?.(event.data, player),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const STATE = {
|
||||
UNSTARTED: -1,
|
||||
ENDED: 0,
|
||||
PLAYING: 1,
|
||||
PAUSED: 2,
|
||||
BUFFERING: 3,
|
||||
CUED: 5,
|
||||
};
|
||||
Reference in New Issue
Block a user