watchparty/client/src/views/player/Player.ts
Nicolas 62b0abd572
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s
Fix casting past the first segment, and the toolbar's real auto-hide bug
Two bugs reported together, both surfaced by actually trying this for
real.

Casting: "the .m3u8 needs to be passed instead of a raw [url]". The
master playlist itself was fine - the initial master.m3u8?castToken=...
request already carries the token. What broke immediately after: every
URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy, and that rewrite never carried the castToken forward. A browser
never noticed (it's still sending its cookie regardless of the URL), but
a Chromecast has no cookie to fall back on, so every segment/variant-
playlist fetch after the first one 401'd, breaking playback moments
after the receiver appeared to connect. Fixed by threading the same
castToken into every rewritten line (rewritePlaylist(text, routeKey,
castToken?), sourced from req.query.castToken right inside
proxyResponse). The self-hosted burn-in path had the identical gap for
a different reason - ffmpeg's own bare relative segment filenames drop
any query string when resolved against a URL that had one, per ordinary
URL-resolution rules - fixed the same way with a new pure
appendCastTokenToSegments(text, castToken), extracted specifically so
it's unit-testable without a real ffmpeg session.

Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix
from two commits ago hadn't actually landed for anyone. Real bug, not a
browser quirk this time: becomeHost's 3-second heartbeat got wired to
the same onDriverStateChanged function real discrete play/pause/seeked
events use, which also re-arms the auto-hide countdown - correct for a
genuine event, wrong for a heartbeat that fires unconditionally every
3s regardless of any real change. With the hide delay also at 3000ms,
every heartbeat tick cancelled and restarted the countdown on the exact
cadence it needed to run uninterrupted to ever complete - so for a
host, on any platform, the bar could never actually finish hiding.
Fixed by giving the heartbeat its own function, reportHeartbeat - same
state sync, without the scheduleControlsHide call.

Verified: full typecheck/test/build (122/122 server tests, including
new coverage for rewritePlaylist's token propagation - had none at all
before this - and appendCastTokenToSegments). Confirmed the heartbeat
fix directly: 3 simulated heartbeat ticks call the hide-timer's reset
zero times, one simulated real event still calls it once. Not verified:
a real Chromecast fetching real rewritten segment URLs - no device
reachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:18:29 +02:00

1159 lines
56 KiB
TypeScript

import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } from '../../cast';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
const REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway - shared by both request types, each throttled independently
const REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
const CONTROLS_HIDE_DELAY_MS = 3000; // how long #bottomBar stays fully expanded after the last bit of activity, before collapsing to just its seek bar
// hls.js's own defaults (10s manifest/level/frag load timeouts) assume a
// file server or a source already being direct-played/remuxed - too tight
// for this app, where every load/reload (an audio/quality change, the
// initial load itself) chains through our own proxy into a FRESH Emby
// transcode (see CatalogService.negotiateStreamTarget/PartyStreamService).
// Confirmed for real: a forced re-encode (this used to be how subtitles
// worked too, before they moved to a native <track> - see
// negotiateStreamTarget's own comment for why) measurably took Emby
// longer than 10s to produce a first segment for,
// and hls.js gave up with a fatal fragLoadTimeOut after its default 4
// retries - not a hang, a real but slow transcode start being mistaken
// for a dead server. Same config duplicated in musicBar/MusicBar.ts (its
// own HLS_CONFIG) rather than shared - the two players' hls.js setup is
// already independently duplicated for the same reason (see that file's
// own loadTrack).
const HLS_CONFIG: HlsConfig = {
manifestLoadingTimeOut: 30000,
manifestLoadingMaxRetry: 2,
levelLoadingTimeOut: 30000,
fragLoadingTimeOut: 60000
};
// "Quality" is really "max transcoding bitrate", the same simplification
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate
// switching. See docs/architecture.md.
const QUALITY_PRESETS = [
{ label: 'Auto (qualité source)', value: '' },
{ label: '1080p (~8 Mbps)', value: '8000000' },
{ label: '720p (~4 Mbps)', value: '4000000' },
{ label: '480p (~1.5 Mbps)', value: '1500000' }
];
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
// specifically so this small a badge stays a crisp two-bar shape regardless
// of the platform's emoji font - pause/play glyphs default to emoji
// presentation on several platforms, which looks muddy at 10px.
const PAUSE_ICON_SVG = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`;
// #fullscreenBtn's icon, swapped in place (updateFullscreenBtn) rather
// than kept as two separate elements - same "one small SVG string
// constant" pattern as PAUSE_ICON_SVG above, just two of them since this
// one's shape genuinely changes (outward vs inward corners), not just a
// class toggle.
const ENTER_FULLSCREEN_SVG = `
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>`;
const EXIT_FULLSCREEN_SVG = `
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>`;
// #playPauseBtn's icon, swapped in place (updatePlayPauseBtn) - the
// custom equivalent of the browser's own native <video controls> play/
// pause button, which this app never enables any more (see becomeHost's
// own comment for why: that native bar used to render as its own
// always-on-top layer fighting this one for the same space).
const PLAY_BTN_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 2 L14 8 L3 14 Z" fill="#fff"></path>
</svg>`;
const PAUSE_BTN_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="3" y="2" width="3.5" height="12" fill="#fff"></rect>
<rect x="9.5" y="2" width="3.5" height="12" fill="#fff"></rect>
</svg>`;
// #muteBtn's icon - local-only (video.muted directly, never synced -
// see updateMuteBtn's own comment), so unlike every other bottom-bar
// icon this one never reflects activeDriver/party state.
const UNMUTED_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M1 6h3l4-3.2v10.4l-4-3.2H1z" fill="#fff"></path>
<path d="M11 5.3a3 3 0 0 1 0 5.4" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></path>
</svg>`;
const MUTED_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M1 6h3l4-3.2v10.4l-4-3.2H1z" fill="#fff"></path>
<line x1="10.5" y1="5" x2="14.5" y2="9" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></line>
<line x1="14.5" y1="5" x2="10.5" y2="9" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></line>
</svg>`;
/**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once. A video party still gets the full-page theater-mode takeover
* (renderVideoPlayer below); a music party does NOT render a page here at
* all - it attaches the persistent bottom bar (musicBar/MusicBar.ts) and
* bounces back to the page the user was already effectively on, so a
* "Rejoindre"/"Écouter ensemble" click never yanks them into a dedicated
* player screen the way starting a video does. Joining/creating a video
* party also always leaves any active listen party first (leaveMusicParty
* is a no-op if there isn't one) - you can't stay in a shared audio queue
* while also taking over the screen for a video watch party.
*
* Has to return its cleanup function *synchronously* (route()'s contract -
* see router.ts), but which cleanup function that even is depends on a
* fetch that hasn't resolved yet at the time this returns. Solved with a
* mutable box: an outer cleanup wrapper is handed back immediately, and it
* calls whatever the real cleanup turns out to be once the fetch settles -
* or nothing at all, correctly, if the fetch is still pending or failed
* when it runs (nothing was ever set up in that case - no socket, no
* timer, nothing to leak). The music branch never sets `cleanup` at all -
* musicBar/MusicBar.ts manages its own lifetime independently of this route.
*/
export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn {
const partyId = params.id as string;
root.innerHTML = LOADING_HINT;
let cleanup: CleanupFn = () => {};
let cancelled = false;
getParty(partyId).then((party) => {
if (cancelled) return;
if (party.mediaType === 'music') {
joinMusicParty(partyId, party);
navigate('/home');
return;
}
leaveMusicParty();
cleanup = renderVideoPlayer(root, partyId, party) || (() => {});
}).catch((err) => {
if (cancelled) return;
// Full error (not just .message) to the console - this also catches a
// rendering bug in renderVideoPlayer itself, not just a genuine "party
// not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err);
alert(err.message);
navigate('/home');
});
return function outerCleanup() {
cancelled = true;
cleanup();
};
}
/**
* Renders the video half of a party - called by the dispatcher above once
* it already knows (from a fetched `party`) that mediaType isn't 'music'.
* Takes the already-fetched party instead of fetching it itself: the
* dispatcher needs to fetch it anyway just to read mediaType, and
* route()'s contract needs its cleanup function back *synchronously* (see
* router.ts) - an async renderVideoPlayer awaiting its own fetch would
* return a Promise instead, which router.ts's `typeof cleanup ===
* 'function'` check would silently skip, reintroducing the exact leak
* class this app already spent real effort fixing once.
*/
function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
root.innerHTML = html;
const playerWrap = root.querySelector('.playerWrap') as HTMLElement;
const video = root.querySelector('#video') as HTMLVideoElement;
const castingScreen = root.querySelector('#castingScreen')!;
const loadingNotice = root.querySelector('#loadingNotice')!;
const errorBox = root.querySelector('#playerError')!;
const errorText = root.querySelector('#playerErrorText')!;
const itemTitleEl = root.querySelector('#itemTitle')!;
const membersEl = root.querySelector('#members')!;
const presenceOverlay = root.querySelector('#presenceOverlay')!;
const requestBanner = root.querySelector('#requestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const ambientNotice = root.querySelector('#ambientNotice')!;
const requestToolbar = root.querySelector('#requestToolbar')!;
const requestPauseBtn = root.querySelector('#requestPauseBtn') as HTMLButtonElement;
const requestResumeBtn = root.querySelector('#requestResumeBtn') as HTMLButtonElement;
const settingsWrap = root.querySelector('#settingsWrap')!;
const settingsBtn = root.querySelector('#settingsBtn')!;
const settingsPanel = root.querySelector('#settingsPanel')!;
const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement;
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement;
const progressFill = root.querySelector('#progressFill') as HTMLElement;
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
const timeLabel = root.querySelector('#timeLabel')!;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
// Named (not inline-anonymous) specifically so cleanup() below can remove
// this exact listener - document.addEventListener is a page-lifetime
// target, so an anonymous one added on every visit to this route would
// never be reachable to remove, and would pile up across visits. This was
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
// used to leave one of these (plus the heartbeat interval, the socket,
// and the hls.js instance) running forever - see router.ts's cleanup hook.
function onDocumentClick(e: MouseEvent) {
if (!settingsWrap.contains(e.target as Node)) settingsPanel.classList.add('hide');
}
document.addEventListener('click', onDocumentClick);
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPanel.classList.toggle('hide');
});
let isHost = false;
let hasNextQueued = false;
let conn: PartyConnection | null = null;
let hls: HlsInstance | null = null;
let currentItemId: string | null = null;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat: number | null = null;
let pauseRequestCooldownUntil = 0;
let resumeRequestCooldownUntil = 0;
let requestBannerTimer: number | null = null;
let controlsHideTimer: number | null = null;
let isPointerOverBottomBar = false;
// The party's shared self-hosted-ffmpeg burn-in choice (see
// TranscodeService) - null means "no burn-in, everyone streams their
// own normal per-viewer Emby-proxied copy" - initialized from the
// party's own current state (party.burnIn*) so a late joiner picks it
// up immediately, not just from a live "burnInChanged" broadcast.
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
let burnInAudioIndex: string | null = party.burnInAudioIndex;
// The party's shared *text* subtitle choice - mutually exclusive with
// burnInSubtitleIndex above (only one subtitle can show at once; the
// server enforces this too, see SocketGateway's setBurnInSubtitle/
// setTextSubtitle handlers). Unlike burn-in, this never changes the
// stream itself - every member still renders it locally as a native
// <track> (see applySubtitleTrack) - so this is only the *sync*, not
// a rendering mechanism of its own.
let textSubtitleIndex: string | null = party.textSubtitleIndex;
// The party's shared normal stream's audio/quality choice (see
// PartyStreamService) - there's only one stream now, not one per
// viewer, so - like burnIn* above - this is party-wide state read from
// the party itself on load, not each viewer's own local pick any more.
// null means "Emby's own default", same as an untouched picker meant
// before streams were shared.
let streamAudioIndex: string | null = party.streamAudioIndex;
let streamQuality: string | null = party.streamQuality;
// Cached purely so becomeHost (host status can change live mid-session
// - see its own comment) can re-render the subtitle picker's host-only
// gating for burn-in options without a redundant playback-options fetch.
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
// "The player" sendLocalState/applyRemoteState actually read and write -
// the local <video> by default, swapped for a Chromecast's RemotePlayer
// while casting (see toggleCast/cast.ts). Casting is available to any
// member, not just the host - a non-host caster still only ever
// *receives* applyRemoteState, same as their local <video> already did
// (see docs/architecture.md's "Casting to a Chromecast" section).
let activeDriver: PlaybackDriver = localDriver(video);
let isCasting = false;
function stopCasting() {
if (!isCasting) return;
isCasting = false;
castBtn.setAttribute('aria-pressed', 'false');
castingScreen.classList.add('hide');
activeDriver = localDriver(video);
// Local muting is meaningful again now that the local <video> is
// what's actually producing sound - see updateMuteBtn's own comment
// for why it's hidden while casting instead of controlling the
// (silent) local element for nothing.
muteBtn.classList.remove('hide');
updatePlayPauseBtn();
updateProgressBar();
updateAmbientNotice();
stopCast();
revokeCastToken(partyId).catch(() => {});
}
/**
* The bottom bar's center zone (see Player.html/Player.css) - never
* more than one message at a time. Casting takes priority over "L'hôte
* contrôle la lecture": it's the more useful thing to know in the
* moment (it explains why the local video area looks different), and
* showing both at once would just be two lines fighting a one-line
* slot. Called whenever either input changes - becomeHost, startCasting/
* stopCasting, and once at init.
*/
function updateAmbientNotice() {
if (isCasting) {
ambientNotice.textContent = 'Diffusion en cours sur le Chromecast';
ambientNotice.classList.remove('hide');
} else if (!isHost) {
ambientNotice.textContent = "L'hôte contrôle la lecture";
ambientNotice.classList.remove('hide');
} else {
ambientNotice.classList.add('hide');
}
}
// Used by both the cast button's own "stop casting" click and a session
// ending on its own (the receiver's remote, the TV losing power) -
// captures wherever the TV left off before tearing the cast driver
// down, then rebuilds local playback from there via the same loadVideo
// path any other reload already uses (audio/quality change, "Réessayer").
function endCastAndResync() {
const resumeSeconds = activeDriver.currentTime;
const wasPlaying = !activeDriver.paused;
stopCasting();
if (currentItemId) loadVideo(currentItemId, resumeSeconds, wasPlaying);
}
/**
* Starts casting the party's current shared stream - available to any
* member, not just the host (see docs/architecture.md). Mints a
* short-lived castToken (CastTokenService) scoping the Chromecast into
* the exact same party-stream/burn relay every other client already
* uses - never a second Emby stream, and the real Emby token never
* leaves this server. On success, tears down the local hls.js/`<video>`
* (nothing left for this device to decode while the TV does) and
* re-anchors the party to wherever the TV actually started, same as any
* other driver swap.
*/
async function startCasting() {
try {
const { token } = await issueCastToken(partyId);
const streamPath = burnInSubtitleIndex ? burnInStreamUrl(partyId) : partyStreamUrl(partyId);
const contentUrl = `${location.origin}${streamPath}?castToken=${encodeURIComponent(token)}`;
const driver = await startCast(contentUrl, itemTitleEl.textContent || 'Watch Party', onDriverStateChanged);
if (hls) { hls.destroy(); hls = null; }
video.pause();
activeDriver = driver;
isCasting = true;
castBtn.setAttribute('aria-pressed', 'true');
castingScreen.classList.remove('hide');
// Nothing left for it to usefully control - the local <video> is
// paused/silent while the TV plays (see updateMuteBtn's own comment).
muteBtn.classList.add('hide');
updateAmbientNotice();
onDriverStateChanged();
} catch (err) {
// The user cancelling the device picker rejects requestSession() the
// same way a real failure does - neither is worth an alert() here,
// just leave the local <video> driving, same as any other "the thing
// the user tried didn't work" path in this app.
console.error('[watchparty player] cast failed', err);
revokeCastToken(partyId).catch(() => {});
}
}
function showError(message: string, detail?: unknown) {
loadingNotice.classList.add('hide');
errorText.textContent = message;
errorBox.classList.remove('hide');
console.error('[watchparty player]', message, detail || '');
}
function clearError() {
errorBox.classList.add('hide');
}
function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) {
suppressLocalEvents = true;
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
if (Math.abs(activeDriver.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
activeDriver.currentTime = targetSeconds;
}
if (msg.isPaused && !activeDriver.paused) activeDriver.pause();
if (!msg.isPaused && activeDriver.paused) activeDriver.play();
updateProgressBar(); // a follower's own read-only bar - see its own comment
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
// Reads/writes activeDriver, not `video` directly - the local <video>
// element or a Chromecast's RemotePlayer, whichever is actually
// driving playback right now (see toggleCast/cast.ts). Both the local
// <video>'s own play/pause/seeked listeners (becomeHost, below) and a
// cast session's RemotePlayerController events (startCast's own
// onRemoteStateChanged callback) funnel through this same function.
function sendLocalState() {
if (!isHost || suppressLocalEvents || !conn) return;
conn.sendPlaystate(activeDriver.paused, Math.round(activeDriver.currentTime * TICKS_PER_SECOND));
}
/** #playPauseBtn's own icon - the custom equivalent of the native
* <video controls> play/pause button (see becomeHost's comment for
* why that's never enabled any more). Host-only (the button itself is
* hidden for a follower), but harmless to call unconditionally - see
* onDriverStateChanged below. */
function updatePlayPauseBtn() {
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
}
/** #muteBtn - deliberately reads/writes `video.muted` directly, never
* activeDriver: muting is this device's own local presentation, not
* party state - there is nothing to sync, and no meaning to it while
* casting (the local <video> already produces no sound then - see
* startCasting/stopCasting, which hide this button entirely instead). */
function updateMuteBtn() {
muteBtn.innerHTML = video.muted ? MUTED_ICON_SVG : UNMUTED_ICON_SVG;
muteBtn.setAttribute('aria-pressed', String(video.muted));
}
/** The seek bar's fill + time label - reads activeDriver, so it
* already reflects whichever of local/cast is actually playing.
* Called continuously (progressTicker, below) so it keeps moving
* between discrete events, and once immediately after anything that
* changes position/duration outside that ticker's own cadence
* (seeking, a driver swap, a fresh applyRemoteState). */
function updateProgressBar() {
const duration = activeDriver.duration;
const current = activeDriver.currentTime;
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
}
/** Shared by every discrete play/pause/seek event this player reacts
* to - the local <video>'s own native events (becomeHost) and a cast
* session's RemotePlayerController events (startCasting's own
* onRemoteStateChanged callback) alike - so the button icon, the
* seek bar, and the party broadcast all stay in lockstep regardless
* of which driver or which gesture actually caused the change.
* Re-arms the auto-hide countdown too (e.g. resuming from pause must
* restart it, not leave the bar stuck open forever) - deliberately
* NOT used for the periodic heartbeat below, which needs the same
* state-sync but must NOT touch the countdown - see its own comment
* for the real bug that came from getting this mixed up once. */
function onDriverStateChanged() {
updatePlayPauseBtn();
updateProgressBar();
sendLocalState();
scheduleControlsHide();
}
/**
* The 3s heartbeat's own tick - reported "still playing at position
* X" regardless of whether anything actually changed, unlike every
* other caller of onDriverStateChanged above, which only ever fires
* on a genuine discrete event. Deliberately does NOT call
* scheduleControlsHide(): a real bug, not a hypothetical one - with
* CONTROLS_HIDE_DELAY_MS also at 3000ms, wiring the heartbeat straight
* to onDriverStateChanged meant it re-armed the auto-hide countdown
* on the same cadence the countdown itself needed to elapse
* uninterrupted to ever fire - so for a host, the bar could never
* auto-hide at all, on any platform, unless they happened to stop
* touching the player at exactly the right phase relative to the
* heartbeat's own timer. icon/progress bar are cheap to refresh here
* too (the dedicated progressTicker already does the latter far more
* often, so this is only a safety net for the former).
*/
function reportHeartbeat() {
updatePlayPauseBtn();
updateProgressBar();
sendLocalState();
}
let isDraggingProgress = false;
function seekFromPointerEvent(e: PointerEvent) {
const duration = activeDriver.duration;
if (!duration) return;
const rect = progressBar.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
activeDriver.currentTime = fraction * duration;
onDriverStateChanged();
}
// Host-only (checked here, not just via the readOnly class - that one
// is purely a cursor/visual hint, see Player.css). Pointer events
// (not click/drag) so mouse and touch share one implementation, and
// setPointerCapture keeps the drag tracking even if the pointer leaves
// the bar's own bounds mid-gesture.
progressBar.addEventListener('pointerdown', (e) => {
if (!isHost) return;
isDraggingProgress = true;
progressBar.setPointerCapture(e.pointerId);
seekFromPointerEvent(e);
});
progressBar.addEventListener('pointermove', (e) => {
if (!isDraggingProgress) return;
seekFromPointerEvent(e);
});
progressBar.addEventListener('pointerup', () => { isDraggingProgress = false; });
progressBar.addEventListener('pointercancel', () => { isDraggingProgress = false; });
playPauseBtn.addEventListener('click', () => {
if (activeDriver.paused) activeDriver.play(); else activeDriver.pause();
});
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
updateMuteBtn();
});
// Ticks the seek bar/time label between discrete events - currentTime
// moves continuously while playing, with no event of its own for
// "still playing, time passed" the way play/pause/seeked have. Runs
// for everyone (host and follower alike - a follower's bar is
// read-only but still needs to visibly move), cheap no-op via the
// currentItemId guard whenever nothing's loaded at all.
let progressTicker: number | null = null;
progressTicker = window.setInterval(() => {
if (!currentItemId) return;
updateProgressBar();
}, 500);
updatePlayPauseBtn();
updateMuteBtn();
/**
* Auto-hides #bottomBar down to just its seek bar (see .bottomBar
* .minimal in Player.css) after a stretch of no activity - asked for
* directly: the full bar, always visible, sits right where a native
* <track> subtitle renders (see applySubtitleTrack) and was masking
* it. Applies to everyone, host or follower - the bar exists (and
* would mask subtitles) for both. Stays expanded while genuinely
* paused (nothing playing to hide from) or while the pointer is
* sitting on the bar itself, and re-arms on every discrete playback
* event too (onDriverStateChanged above), not just raw pointer
* activity - resuming from pause has to restart the countdown, not
* leave the bar stuck open forever.
*/
function clearControlsHideTimer() {
if (controlsHideTimer) {
clearTimeout(controlsHideTimer);
controlsHideTimer = null;
}
}
function scheduleControlsHide() {
clearControlsHideTimer();
controlsHideTimer = window.setTimeout(() => {
if (activeDriver.paused || isPointerOverBottomBar) return;
bottomBar.classList.add('minimal');
}, CONTROLS_HIDE_DELAY_MS);
}
function showControls() {
bottomBar.classList.remove('minimal');
scheduleControlsHide();
}
// Mouse only (pointerType check) - not just an optimization. A touch
// pointer has no real "hovering": tapping the bar fires a pointerenter
// with no matching pointerleave ever coming (there's no cursor left to
// lift away), which used to latch isPointerOverBottomBar permanently
// true after the very first tap on the bar - the whole thing could
// never auto-hide again from that point on, on any touch device.
// Touch's own "wake the bar up" path is already covered below by the
// plain pointerdown/pointermove listeners on the whole player.
function onBottomBarPointerEnter(e: PointerEvent) {
if (e.pointerType !== 'mouse') return;
isPointerOverBottomBar = true;
showControls();
}
function onBottomBarPointerLeave(e: PointerEvent) {
if (e.pointerType !== 'mouse') return;
isPointerOverBottomBar = false;
scheduleControlsHide();
}
playerWrap.addEventListener('pointermove', showControls);
playerWrap.addEventListener('pointerdown', showControls);
bottomBar.addEventListener('pointerenter', onBottomBarPointerEnter);
bottomBar.addEventListener('pointerleave', onBottomBarPointerLeave);
scheduleControlsHide();
/**
* Promotes this client to host - called either at init (if we already
* were) or on a live "hostChanged" event (the previous host left and the
* server picked us next - see SocketGateway's applyLeave). Safe to call
* more than once: the early return covers the normal case, and
* addEventListener with the same function reference is a no-op if
* already attached, so even a re-entrant call can't double up the
* play/pause/seeked listeners. The heartbeat is guarded separately since
* setInterval has no such built-in idempotence - calling it twice would
* leak the first interval's id.
*/
function becomeHost(notify: boolean) {
if (isHost) return;
isHost = true;
// Native <video controls> deliberately never enabled - see
// Player.html's own comment on #bottomBar: that native bar used to
// render as its own always-on-top layer, invisible in fullscreen
// before that was fixed and directly overlapping/duplicating this
// one (its own cast icon, its own fullscreen toggle) once it was.
// playPauseBtn/progressBar below are the custom replacement.
settingsWrap.classList.remove('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued);
playPauseBtn.classList.remove('hide');
progressBar.classList.remove('readOnly');
updateAmbientNotice();
video.addEventListener('play', onDriverStateChanged);
video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', onDriverStateChanged);
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
// Burn-in (image) subtitle options are host-only (see
// renderSubtitleOptions) - becoming host mid-session must unlock
// them immediately, not just on this player's next full re-render.
// Same reasoning for the audio/quality selects themselves now that
// there's only one shared stream to control, not each viewer's own.
renderSubtitleOptions();
applyStreamOptionsGating();
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
async function loadTrackOptions(itemId: string) {
try {
const options = await getPlaybackOptions(itemId);
lastPlaybackOptions = options;
audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>';
// Restores the party's *current* audio/quality choice - only
// meaningful for audio now that its options actually exist to
// match against; quality's own options are static (QUALITY_PRESETS,
// set once at the top of renderVideoPlayer) so this is really only
// needed here for symmetry/simplicity, not because it couldn't be
// set any earlier. Guarded (not a plain `|| ''` fallback) because
// an audio <option> never has value="" (only the no-tracks-at-all
// fallback branch above does) - assigning '' when streamAudioIndex
// is null would match nothing, leaving the select's selectedIndex
// at -1 and rendering as an empty-looking choice instead of falling
// back to the first real option the way leaving .value untouched does.
if (streamAudioIndex) audioSelect.value = streamAudioIndex;
qualitySelect.value = streamQuality || '';
renderSubtitleOptions();
applyStreamOptionsGating();
} catch (err) {
console.error('[watchparty player] playback-options failed', err);
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
}
}
/**
* (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/
* text-subtitle/host state changes (becomeHost, onBurnInChanged,
* onTextSubtitleChanged) without a fresh fetch.
*
* Every option here is host-only and party-wide now (subtitle choice
* used to be each viewer's own local pick, but that turned out to be
* the wrong call once people actually watched together - see
* docs/architecture.md) - but the two kinds still work very
* differently underneath. isTextBased: false means this is an image/
* bitmap subtitle (PGS/VobSub/DVB, typically a Blu-ray/DVD rip) - a
* native <track> (see applySubtitleTrack) needs real text, which a
* bitmap has none of for Emby to convert to WebVTT. These go through
* TranscodeService's own self-hosted ffmpeg burn-in instead
* (`data-burn-in` flags which options these are, for the change
* handler below) - an actual re-encode of the shared stream, so
* picking one restarts playback for everyone, the label says so
* instead of leaving it a surprise. A text option is just a sync of
* which native <track> every member renders locally - no reload, no
* transcode cost - see setTextSubtitle/onTextSubtitleChanged.
*/
function renderSubtitleOptions() {
if (!lastPlaybackOptions) return;
const previousValue = subtitleSelect.value;
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
lastPlaybackOptions.subtitleTracks.map((t) => {
if (t.isTextBased === false) {
return `<option value="${t.index}" data-burn-in="1">${escapeHtml(t.label)} (incrustation - redémarre la vidéo pour tous)</option>`;
}
return `<option value="${t.index}">${escapeHtml(t.label)}</option>`;
}).join('');
subtitleSelect.value = burnInSubtitleIndex || textSubtitleIndex || previousValue;
// Host-only and party-wide unconditionally now, same reasoning as
// applyStreamOptionsGating below - a follower has nothing left to
// pick here at all, whether or not a burn-in stream happens to be
// active (unlike audio/quality, this one isn't video-vs-burn-in
// specific - it's just "are you the host").
subtitleSelect.disabled = !isHost;
burnInHint.classList.toggle('hide', !burnInSubtitleIndex);
}
/**
* Audio track/quality are host-only now too, for the same underlying
* reason burn-in options are: there's only one shared stream per party
* (PartyStreamService), not each viewer's own independent copy any
* more, so there's only room for one set of encoding parameters behind
* it - see docs/architecture.md's "One shared Emby stream per party"
* section. Also disabled while a burn-in stream is active (unchanged
* from before - that's ffmpeg's own fixed encode, and neither pick
* applies to it either). Called on every load (loadVideo already runs
* on every relevant transition) and from becomeHost directly, since
* becoming host doesn't otherwise trigger a reload on its own.
*/
function applyStreamOptionsGating() {
const disabled = !!burnInSubtitleIndex || !isHost;
audioSelect.disabled = disabled;
qualitySelect.disabled = disabled;
}
/**
* Adds/replaces the video's own <track> element for whichever subtitle
* (if any) is currently selected - a native browser feature, entirely
* separate from the video stream itself (see CatalogService.
* proxySubtitle for why: a server-side burn-in used to be how this
* worked, and a long real-server debugging session found it unreliable
* in ways this app's own request could never have controlled or
* worked around - see docs/architecture.md's player-track-selection
* section). Removes and re-adds a fresh <track> every time rather than
* changing an existing one's `src` in place - browsers don't reliably
* re-fetch/re-parse a track from a plain attribute change after it's
* already loaded, and a fresh element always does.
*/
function applySubtitleTrack(subtitleIndex: string) {
video.querySelectorAll('track').forEach((t) => t.remove());
if (!subtitleIndex || !currentItemId) return;
const track = document.createElement('track');
track.kind = 'subtitles';
track.src = subtitleUrl(currentItemId, Number(subtitleIndex));
video.appendChild(track);
// A freshly added track defaults to "hidden", not "showing". Set
// synchronously, right here - the browser registers the TextTrack in
// video.textTracks the moment the element is connected, before its
// actual subtitle content has loaded, and honors `mode` regardless.
// Deliberately NOT waiting on the <track>'s own "load" event for
// this: confirmed against a real browser that it doesn't fire
// reliably at all (a known cross-browser <track> quirk, not specific
// to this app), which would have left the track silently never shown.
if (video.textTracks[0]) video.textTracks[0].mode = 'showing';
}
function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) {
// A real reload (a new item, a burn-in/subtitle/stream-options change,
// "Réessayer") always means going back to local playback - keeping a
// live cast session in sync with every one of those transitions
// instead was judged not worth the complexity for v1 (see
// docs/architecture.md's "Casting to a Chromecast" section); casting
// again afterward is one click away, and CAF's own ORIGIN_SCOPED
// auto-join policy (see cast.ts) means that click typically reconnects
// instead of prompting a fresh device picker.
stopCasting();
currentItemId = itemId;
clearError();
loadingNotice.classList.remove('hide');
// Reset immediately rather than waiting for the next progressTicker
// tick (up to 500ms away) - the old item's leftover position/duration
// means nothing for whatever's loading now.
updateProgressBar();
if (hls) {
hls.destroy();
hls = null;
}
// A burn-in stream already has its subtitle composited into the
// video itself - a real <track> on top would be redundant at best.
// Re-applied on every (re)load otherwise, not just when the subtitle
// picker itself changes: an audio/quality change now reloads through
// here too (via onStreamOptionsChanged, once the broadcast comes
// back - see that handler and the setStreamOptions change listener
// below), and the subtitle that was showing should survive that -
// reads textSubtitleIndex (the synced party-wide state, kept current
// by onTextSubtitleChanged/renderSubtitleOptions) rather than
// subtitleSelect.value directly, so it's correct even before the
// select itself has been (re)rendered for this load. A genuinely new
// item (onItemChanged) already reset it to null before this runs, so
// this correctly clears any leftover track from whatever played
// before.
const usingBurnIn = !!burnInSubtitleIndex;
applySubtitleTrack(usingBurnIn ? '' : (textSubtitleIndex || ''));
applyStreamOptionsGating();
// Both branches are already party-shared, one way or the other -
// burnInStreamUrl's own ffmpeg, or PartyStreamService's cached Emby
// negotiation - so neither needs any audio/quality query params any
// more; the party itself (streamAudioIndex/streamQuality, set via
// setStreamOptions below) is what the server reads instead. See
// docs/architecture.md's "One shared Emby stream per party" section.
const src = usingBurnIn ? burnInStreamUrl(partyId) : partyStreamUrl(partyId);
const resume = () => {
loadingNotice.classList.add('hide');
if (resumeSeconds) video.currentTime = resumeSeconds;
if (wasPlaying) video.play().catch(() => {});
};
if (window.Hls && window.Hls.isSupported()) {
hls = new window.Hls(HLS_CONFIG);
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
hls.on(window.Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
showError(
"Erreur de lecture (HLS) - le flux n'a pas pu être chargé depuis Emby.",
data
);
});
hls.loadSource(src);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari plays HLS natively - no hls.js involved, so wire the plain
// <video> element's own error event instead.
video.src = src;
video.addEventListener('loadedmetadata', resume, { once: true });
video.addEventListener('error', () => {
showError('Erreur de lecture vidéo.', video.error);
});
} else {
showError("Ce navigateur ne sait pas lire de flux HLS et hls.js n'a pas pu se charger.");
}
}
// Changing audio/quality picks the party's *shared* stream's encoding
// parameters now (PartyStreamService) - host-only (the selects are
// disabled for a follower to begin with, so this listener only
// realistically ever fires for the host - same defensive shape as the
// subtitleSelect listener below), and does NOT reload here directly:
// every member (host included) only ever reacts to the resulting
// "streamOptionsChanged" broadcast, see onStreamOptionsChanged below -
// same discipline as onItemChanged/applyRemoteState never applying
// anything optimistically before the server's own broadcast confirms it.
[audioSelect, qualitySelect].forEach((sel) => {
sel.addEventListener('change', () => {
settingsPanel.classList.add('hide');
conn?.setStreamOptions(audioSelect.value || null, qualitySelect.value || null);
});
});
// Both kinds of pick are host-only and go through the server now -
// neither branch applies anything locally: every member (including
// this host) only ever reacts to the resulting broadcast
// (onBurnInChanged / onTextSubtitleChanged below), same discipline as
// onItemChanged/applyRemoteState never applying anything optimistically
// first. An image (burn-in) pick goes through "setBurnInSubtitle";
// everything else - a text pick, or "Aucun" - goes through
// "setTextSubtitle", which the server also uses to clear any active
// burn-in (the two are mutually exclusive - see SocketGateway).
subtitleSelect.addEventListener('change', () => {
settingsPanel.classList.add('hide');
const isBurnInPick = subtitleSelect.selectedOptions[0]?.dataset.burnIn === '1';
if (isBurnInPick) {
conn?.setBurnInSubtitle(subtitleSelect.value, audioSelect.value || undefined);
} else {
conn?.setTextSubtitle(subtitleSelect.value || null);
}
});
(root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => {
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
});
initCast();
// Visible to any member the moment a Chromecast is on the network - not
// host-gated, unlike the settings picker above (see startCasting's own
// comment). Both subscriptions return an unsubscribe function this
// component MUST call on cleanup (below) - see cast.ts's own comment on
// why, and this app's general history with leaked listeners/timers
// (docs/architecture.md).
const unsubscribeCastAvailability = onCastAvailabilityChanged((available) => {
castBtn.classList.toggle('hide', !available);
});
// Fires whichever way a cast session actually ends - the Chromecast's
// own remote, the TV losing power, or this same click handler ending it
// (harmless to also reach this via that path - stopCasting()/
// endCastAndResync() below both just no-op if isCasting is already false).
const unsubscribeCastSessionEnded = onCastSessionEnded(endCastAndResync);
castBtn.addEventListener('click', () => {
if (isCasting) endCastAndResync();
else startCasting();
});
function updateFullscreenBtn() {
const isFullscreen = document.fullscreenElement === playerWrap;
fullscreenBtn.innerHTML = isFullscreen ? EXIT_FULLSCREEN_SVG : ENTER_FULLSCREEN_SVG;
fullscreenBtn.title = isFullscreen ? 'Quitter le plein écran' : 'Plein écran';
}
/**
* Fullscreen always targets .playerWrap - the whole player, video AND
* overlay chrome - never the bare <video>. Native per-video fullscreen
* (the browser's own double-click gesture on the video, or the host's
* own native <video controls> fullscreen button - see becomeHost) only
* ever shows that element's own subtree: every sibling overlay (the
* top bar, presence, and critically the request toolbar) would vanish,
* leaving a follower with no way to ask the host for anything. This
* listener fires on ANY fullscreen change, not just ones #fullscreenBtn
* itself caused - if that's what just happened (fullscreenElement is
* the bare <video>, not .playerWrap), it immediately corrects course:
* exit, then re-enter on the wrap instead. See docs/architecture.md's
* "Lecteur vidéo" section for the full reasoning.
*/
function onFullscreenChange() {
if (document.fullscreenElement === video) {
document.exitFullscreen().then(() => playerWrap.requestFullscreen()).catch(() => {});
return;
}
updateFullscreenBtn();
}
document.addEventListener('fullscreenchange', onFullscreenChange);
fullscreenBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
playerWrap.requestFullscreen().catch(() => {});
}
});
updateFullscreenBtn();
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
// A new item's own stream indices/subtitle pick have nothing to do
// with the old one's - PartyService.advanceQueue already resets this
// server-side too (see its own comment); this just keeps this client
// in sync without waiting on a separate broadcast for the same event.
// streamQuality is deliberately left alone - a bandwidth/quality
// preference isn't tied to any one item, same reasoning as
// PartyService.advanceQueue's own comment.
burnInSubtitleIndex = null;
burnInAudioIndex = null;
textSubtitleIndex = null;
streamAudioIndex = null;
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
}
/** The host picked or cleared the party's shared burn-in stream - see
* the subtitleSelect change handler above for why every member
* (host included) only ever reacts to this broadcast rather than
* applying anything optimistically first. Reloads for every member -
* there's no cheaper local-only path since the actual video bytes
* differ now. A fresh page load/rejoin instead reads the party's
* already-current burnIn* fields directly - see the outer variable
* declarations above. */
function onBurnInChanged(msg: BurnInChangedMsg) {
burnInSubtitleIndex = msg.subtitleIndex;
burnInAudioIndex = msg.audioIndex;
// Mutually exclusive with a text subtitle pick - the server already
// clears party.textSubtitleIndex whenever a *real* burn-in is set
// (see SocketGateway's setBurnInSubtitle) but only emits
// "burnInChanged" for it, not a separate "textSubtitleChanged" too -
// so mirror that clear here rather than waiting on a broadcast that
// isn't coming.
if (burnInSubtitleIndex) textSubtitleIndex = null;
renderSubtitleOptions();
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
/** The host picked (or cleared) the party's shared *text* subtitle -
* see the subtitleSelect change handler above for why every member
* (host included) only ever reacts to this broadcast rather than
* applying anything optimistically first. Usually no reload needed -
* unlike burn-in, a text subtitle is still just a native <track>
* each member renders locally (see applySubtitleTrack); only *which*
* one is synced. The exception is picking a text subtitle (or
* "Aucun") while a burn-in was active: the select allows it (host-
* only gating, not burn-in-specific any more - see
* renderSubtitleOptions), and it clears the burn-in for real, which
* DOES change the actual stream - so that specific transition still
* needs the same reload onBurnInChanged uses. A fresh page load/
* rejoin instead reads the party's already-current textSubtitleIndex
* directly - see the outer variable declarations above. */
function onTextSubtitleChanged(msg: TextSubtitleChangedMsg) {
textSubtitleIndex = msg.subtitleIndex;
const wasUsingBurnIn = !!burnInSubtitleIndex;
// Mutually exclusive with burn-in, mirroring the server (see
// SocketGateway's setTextSubtitle, which clears these two
// server-side too but only broadcasts "textSubtitleChanged" - not a
// second "burnInChanged" - for this side of the exclusivity).
if (textSubtitleIndex) {
burnInSubtitleIndex = null;
burnInAudioIndex = null;
}
renderSubtitleOptions();
if (wasUsingBurnIn && !burnInSubtitleIndex) {
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
} else {
applySubtitleTrack(textSubtitleIndex || '');
}
}
/** The host changed the party's shared normal stream's audio track/
* quality - see the audioSelect/qualitySelect change listener above
* for why every member (host included) only ever reacts to this
* broadcast. Reloads for every member - there's only one shared
* stream now, so there's no cheaper per-viewer path any more. A fresh
* page load/rejoin instead reads the party's already-current
* streamAudioIndex/streamQuality directly - see the outer variable
* declarations above. */
function onStreamOptionsChanged(msg: StreamOptionsChangedMsg) {
streamAudioIndex = msg.audioStreamIndex;
streamQuality = msg.quality;
// Same guard as loadTrackOptions above, and for the same reason - an
// audio <option> never has value="", so assigning '' when
// streamAudioIndex is null would match nothing and render as an
// empty-looking choice instead of leaving whichever option was
// already showing.
if (streamAudioIndex) audioSelect.value = streamAudioIndex;
qualitySelect.value = streamQuality || '';
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
// Presence overlay: one semi-transparent circle per member, initials
// only (no photos - Emby accounts don't reliably have one). Purely
// presence now - asking the host for something is the requestToolbar's
// job (see below), not a click on your own circle any more (a small
// avatar was never a very discoverable button). The request-badge
// <span> is always in the markup, just hidden by .avatarActionRequested's
// CSS - see showRequestBanner(), the only thing that ever shows it.
function renderMembers(members: SocketMember[]) {
membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', ');
presenceOverlay.innerHTML = members.map((m) => `
<div
class="avatar"
style="background:${colorForName(m.userName)}"
data-user-id="${escapeHtml(m.userId)}"
title="${escapeHtml(m.userName)}"
>${escapeHtml(initialsFor(m.userName))}<span class="avatarRequestBadge">${PAUSE_ICON_SVG}</span></div>`
).join('');
}
// Broadcast to everyone in the party, including the requester and the
// host (see SocketGateway) - so this always flags the right circle no
// matter whose screen it renders on. Shared by onPauseRequested/
// onResumeRequested below - same visual treatment either way, the
// banner text is what actually says which one happened.
function showRequestBanner(userId: string, userName: string, actionLabel: string) {
requestBanner.textContent = `${userName} demande ${actionLabel}`;
requestBanner.classList.remove('hide');
if (requestBannerTimer) clearTimeout(requestBannerTimer);
requestBannerTimer = window.setTimeout(() => requestBanner.classList.add('hide'), REQUEST_HIGHLIGHT_MS);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(userId)}"]`);
if (avatar) {
avatar.classList.add('avatarActionRequested');
setTimeout(() => avatar.classList.remove('avatarActionRequested'), REQUEST_HIGHLIGHT_MS);
}
}
function onPauseRequested(msg: PauseRequestedMsg) {
showRequestBanner(msg.userId, msg.userName, 'une pause');
}
function onResumeRequested(msg: ResumeRequestedMsg) {
showRequestBanner(msg.userId, msg.userName, 'une reprise');
}
// The toolbar itself - see .requestToolbar in Player.css/Player.html
// for why this replaced the old "click your own avatar" affordance,
// and for why it now lives in the bottom bar's left zone instead of
// its own cornered overlay. Each button has its own client-side
// cooldown (mirroring the server's own independent per-request
// throttle in SocketGateway) purely so a rapid double-click doesn't
// even bother emitting the second request.
requestPauseBtn.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause();
});
requestResumeBtn.addEventListener('click', () => {
const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume();
});
async function init() {
itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext;
updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost);
progressBar.classList.toggle('readOnly', !party.youAreHost);
// Before loadTrackOptions: renderSubtitleOptions' host-only gating
// for burn-in options reads `isHost`, which becomeHost is what sets -
// otherwise the actual host's very first render would show every
// image-subtitle option as disabled, as if they were a follower.
if (party.youAreHost) becomeHost(false);
await loadTrackOptions(party.itemId);
loadVideo(party.itemId, 0, false);
conn = connectParty(partyId, {
onMembers: renderMembers,
onPlaystate: applyRemoteState,
onItemChanged,
onHostChanged: () => becomeHost(true),
onPauseRequested,
onResumeRequested,
onBurnInChanged,
onTextSubtitleChanged,
onStreamOptionsChanged,
onDeviceConflict: () => {
// This socket is still connected but inert (see SocketGateway's
// "join" handler / api/party.ts) - a confirm() is fine here even
// though it blocks: this only ever fires right after opening the
// party, before any real playback state has built up locally to
// interrupt.
const takeOver = confirm(
"Tu es déjà connecté à cette party depuis un autre appareil.\n\nContinuer ici va déconnecter l'autre appareil."
);
if (takeOver) conn?.confirmDeviceTakeover();
else navigate('/home');
},
onError: (message) => alert(message),
onClose: () => {}
});
}
nextBtn.addEventListener('click', () => { conn?.next(); });
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init();
// Called by router.ts right before it renders whatever route comes next -
// whether that's from clicking "Quitter" (navigate above) or leaving any
// other way (sidebar link, browser back, ...). This is the fix for the
// actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() {
if (heartbeat) clearInterval(heartbeat);
if (progressTicker) clearInterval(progressTicker);
if (requestBannerTimer) clearTimeout(requestBannerTimer);
clearControlsHideTimer();
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange);
playerWrap.removeEventListener('pointermove', showControls);
playerWrap.removeEventListener('pointerdown', showControls);
bottomBar.removeEventListener('pointerenter', onBottomBarPointerEnter);
bottomBar.removeEventListener('pointerleave', onBottomBarPointerLeave);
unsubscribeCastAvailability();
unsubscribeCastSessionEnded();
// Deliberately NOT stopping an active cast here - leaving this page
// must not yank the stream off the TV; casting is meant to keep
// going independently of the sender tab, same as any other
// Chromecast-enabled app (see docs/architecture.md).
conn?.leave();
leaveParty(partyId).catch(() => {});
};
}