watchparty/client/src/views/player/Player.ts
Nicolas 6582ad855b
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Link video/music playback to the OS own media controls (Windows SMTC, macOS Now Playing, ...)
New mediaSession.ts wraps the standard Media Session API - shared by
Player.ts and MusicBar.ts, which can never both be active at once so
there is no risk of them fighting over this one global. Metadata/
position/playback-state hook into each players own existing single
source of truth (updatePlayPauseBtn/updateProgressBar, setPlayIcon/
updatePositionDisplay) instead of adding parallel call sites. Play/
pause/seek are wired once and read isHost/conn fresh on every
OS-triggered call, so they keep working through a live host handoff;
a non-host play/pause routes through the same requestPause/
requestResume the request toolbar already uses, and seek is gated on
isHost the same way the in-page seek bar already is. Next is handled
separately since a registered nexttrack handler is what makes the OS
show that control at all - its presence tracks whatever the in-page
equivalent already tracks (isHost && hasNextQueued for video, a vote
skip for music). Cleared entirely on leaving a party/page, or just to
idle (metadata/state only) when a listen partys queue runs empty but
the party itself is still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:41:39 +02:00

1315 lines
64 KiB
TypeScript

import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackMarkers, 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 { showToast } from '../../toast';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, 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';
import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../../mediaSession';
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);
showToast(err.message, { variant: 'error' });
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 pipBtn = root.querySelector('#pipBtn') 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')!;
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
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;
// Emby's own native intro/credits detection for currentItemId, if any -
// see loadMarkers/updateSkipButton below and CatalogService
// .getPlaybackMarkers. Null fields (or this whole thing being null,
// while still loading) just mean no skip button, never an error.
let currentMarkers: PlaybackMarkersDto | null = null;
// One-shot per item - see updateSkipButton's own comment for why this
// must NOT re-fire (force the bar open) on every tick for as long as
// credits are running.
let creditsControlsShown = false;
// "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;
setMediaSessionPlaybackState(!activeDriver.paused);
}
/** #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)}` : '';
setMediaSessionPositionState(duration, current);
}
/**
* Shows/hides #skipIntroBtn from activeDriver's current position against
* currentMarkers - called continuously (progressTicker, below), same as
* updateProgressBar. Host-only: a follower's own seek never broadcasts
* (see sendLocalState's own gate), so a local jump here would just get
* overwritten by the next correction from the real host - same
* reasoning as progressBar being read-only for a follower.
*
* Credits get no button of their own: #nextBtn already does exactly
* "go to the next queued item" and is already shown whenever
* hasNextQueued is true (see onItemChanged) - detection's only real
* contribution there is making sure it's actually reachable the moment
* credits start, by un-minimizing the bar once, rather than requiring
* the host to have touched the player recently. The `creditsControlsShown`
* guard is what keeps this a one-shot nudge instead of fighting anyone
* who deliberately re-minimizes the bar during a long credits scroll.
*/
function updateSkipButton() {
if (!isHost || !currentMarkers) {
skipIntroBtn.classList.add('hide');
return;
}
const current = activeDriver.currentTime;
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
const inIntro = introStartTicks != null && introEndTicks != null
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
skipIntroBtn.classList.toggle('hide', !inIntro);
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
if (inCredits && hasNextQueued && !creditsControlsShown) {
creditsControlsShown = true;
showControls();
}
}
/** 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;
/** Host-only in effect (both call sites already gate on isHost before
* reaching here - the progress bar's own pointerdown handler below,
* and the Media Session seekto action) - this itself just clamps and
* applies, same shape as MusicBar.ts's own seekTo. */
function seekTo(seconds: number) {
const duration = activeDriver.duration;
if (!duration) return;
activeDriver.currentTime = Math.min(Math.max(0, seconds), duration);
onDriverStateChanged();
}
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));
seekTo(fraction * duration);
}
// 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();
});
skipIntroBtn.addEventListener('click', () => {
if (currentMarkers?.introEndTicks == null) return;
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
onDriverStateChanged();
});
// 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();
updateSkipButton();
}, 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. .playerWrap also gets
* .cursorHidden alongside .minimal (see Player.css) - asked for
* directly too: a mouse cursor left sitting over the video is exactly
* the same kind of clutter the bar itself was, once there's nothing
* left under it to interact with.
*/
function clearControlsHideTimer() {
if (controlsHideTimer) {
clearTimeout(controlsHideTimer);
controlsHideTimer = null;
}
}
function scheduleControlsHide() {
clearControlsHideTimer();
controlsHideTimer = window.setTimeout(() => {
if (activeDriver.paused || isPointerOverBottomBar) return;
bottomBar.classList.add('minimal');
playerWrap.classList.add('cursorHidden');
}, CONTROLS_HIDE_DELAY_MS);
}
function showControls() {
bottomBar.classList.remove('minimal');
playerWrap.classList.remove('cursorHidden');
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);
setMediaSessionNextAction(hasNextQueued ? goToNextItem : null);
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) showToast("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.
}
}
/** Emby's own native intro/credits markers for `itemId`, if the library
* it's from has detection enabled and the task has already reached it
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
* not just on a fetch failure: without this, the OLD item's markers
* (and skip button) would keep showing for however long this fetch
* takes on the new one. */
async function loadMarkers(itemId: string) {
currentMarkers = null;
skipIntroBtn.classList.add('hide');
creditsControlsShown = false;
try {
currentMarkers = await getPlaybackMarkers(itemId);
} catch (err) {
console.error('[watchparty player] playback markers failed', err);
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
}
}
/**
* (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();
/**
* Picture-in-picture - asked for directly, mobile in particular: the
* one way to keep watching in a small floating window while switching
* to another app, since a phone has no second window/screen to put
* this app in the way a desktop might. Feature-detected once at mount,
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
* Chromecast appearing/disappearing on the network mid-session,
* browser support for this is fixed for the lifetime of the page, so
* there's nothing to react to after this first check. video itself
* needs no new attribute for this to work - unlike disableremoteplayback
* (added specifically to suppress Chrome's own auto cast icon), there's
* no equivalent auto-injected native PiP control this app needs to
* fight with.
*/
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
pipBtn.classList.remove('hide');
pipBtn.addEventListener('click', () => {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
} else {
video.requestPictureInPicture().catch((err) => {
// Can genuinely fail even when supported (no user gesture in
// some browsers' stricter reading of that, video not yet
// ready) - same "try it, log it, don't crash anything else"
// instinct as every other best-effort browser API this app
// calls (video.play().catch, requestFullscreen().catch, ...).
console.error('[watchparty player] picture-in-picture failed', err);
});
}
});
// Reflects state via the same [aria-pressed] treatment #castBtn
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
// browser's own PiP window being closed directly (its native close
// button, or the OS), not just this button's own click.
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
}
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
// 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;
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
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.
function requestPause() {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause();
}
function requestResume() {
const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume();
}
requestPauseBtn.addEventListener('click', requestPause);
requestResumeBtn.addEventListener('click', requestResume);
function goToNextItem() {
conn?.next();
}
async function init() {
itemTitleEl.textContent = party.itemName || '';
setMediaSessionMetadata(party.itemName || '', '', '', `/api/catalog/image/${party.itemId}`);
// Play/pause/seek are wired exactly once - see setMediaSessionActionHandlers'
// own comment for why they never need re-wiring on a host handoff.
// "next" isn't included here: unlike play/pause (always meaningful,
// just routed differently for a follower - see requestPause/Resume
// above), a *registered* nexttrack handler is what makes Windows show
// the control at all, so it has to track hasNextQueued/isHost the
// same way #nextBtn's own visibility does - see becomeHost/onItemChanged.
setMediaSessionActionHandlers({
onPlay: () => { if (isHost) activeDriver.play(); else requestResume(); },
onPause: () => { if (isHost) activeDriver.pause(); else requestPause(); },
onSeek: (seconds) => { if (isHost) seekTo(seconds); }
});
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);
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
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) => showToast(message, { variant: 'error' }),
onClose: () => {}
});
}
nextBtn.addEventListener('click', goToNextItem);
(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();
clearMediaSession();
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(() => {});
};
}