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>
1315 lines
64 KiB
TypeScript
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(() => {});
|
|
};
|
|
}
|