import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js'; import { getParty, leaveParty, connectParty } from '../party.js'; import { navigate } from '../router.js'; import { escapeHtml, colorForName, initialsFor } from '../tiles.js'; import { renderMusicPlayer } from './musicPlayer.js'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const DRIFT_TOLERANCE_SECONDS = 1.5; const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up // "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 = ` `; /** * Route `/party/:id` - dispatches on the party's mediaType, fetched here * once, since both renderVideoPlayer and renderMusicPlayer need to know it * before they can render anything at all. * * Has to return its cleanup function *synchronously* (route()'s contract - * see router.js), 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). */ export function renderPartyPlayer(root, params) { const partyId = params.id; root.innerHTML = '

Chargement...

'; let cleanup = () => {}; let cancelled = false; getParty(partyId).then((party) => { if (cancelled) return; cleanup = (party.mediaType === 'music' ? renderMusicPlayer : 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/renderMusicPlayer itself, not just // a genuine "party not found", so the stack trace matters here. console.error('[watchparty player] failed to open party', err); alert(err.message); navigate('/home'); }); return function outerCleanup() { cancelled = true; cleanup(); }; } /** * Renders the video half of a party - called by the dispatcher above once * it already knows (from a fetched `party`) that mediaType isn't 'music'. * Takes the already-fetched party instead of fetching it itself: the * dispatcher needs to fetch it anyway just to read mediaType, and * route()'s contract needs its cleanup function back *synchronously* (see * router.js) - an async renderVideoPlayer awaiting its own fetch would * return a Promise instead, which router.js's `typeof cleanup === * 'function'` check would silently skip, reintroducing the exact leak * class this app already spent real effort fixing once. */ function renderVideoPlayer(root, partyId, party) { root.innerHTML = `

Chargement du flux...

L'hôte contrôle la lecture.

`; const video = root.querySelector('#video'); 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 pauseBanner = root.querySelector('#pauseRequestBanner'); const nextBtn = root.querySelector('#nextBtn'); const followerNotice = root.querySelector('#followerNotice'); const settingsWrap = root.querySelector('#settingsWrap'); const settingsBtn = root.querySelector('#settingsBtn'); const settingsPanel = root.querySelector('#settingsPanel'); const audioSelect = root.querySelector('#audioSelect'); const subtitleSelect = root.querySelector('#subtitleSelect'); const qualitySelect = root.querySelector('#qualitySelect'); qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).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.js's cleanup hook. function onDocumentClick(e) { if (!settingsWrap.contains(e.target)) 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 = null; let hls = null; let currentItemId = null; let suppressLocalEvents = false; // avoid feeding our own correction back as a new event let heartbeat = null; let pauseRequestCooldownUntil = 0; let pauseBannerTimer = null; function showError(message, detail) { 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) { suppressLocalEvents = true; const targetSeconds = msg.positionTicks / TICKS_PER_SECOND; if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) { video.currentTime = targetSeconds; } if (msg.isPaused && !video.paused) video.pause(); if (!msg.isPaused && video.paused) video.play().catch(() => {}); setTimeout(() => { suppressLocalEvents = false; }, 250); } function sendLocalState() { if (!isHost || suppressLocalEvents || !conn) return; conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND)); } /** * 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 socketHandler.js'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) { if (isHost) return; isHost = true; video.controls = true; settingsWrap.classList.remove('hide'); followerNotice.classList.add('hide'); nextBtn.classList.toggle('hide', !hasNextQueued); video.addEventListener('play', sendLocalState); video.addEventListener('pause', sendLocalState); video.addEventListener('seeked', sendLocalState); video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); }); if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000); if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte."); } async function loadTrackOptions(itemId) { try { const options = await getPlaybackOptions(itemId); audioSelect.innerHTML = options.audioTracks.length ? options.audioTracks.map((t) => ``).join('') : ''; subtitleSelect.innerHTML = '' + options.subtitleTracks.map((t) => ``).join(''); } 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. } } function loadVideo(itemId, resumeSeconds, wasPlaying) { currentItemId = itemId; clearError(); loadingNotice.classList.remove('hide'); if (hls) { hls.destroy(); hls = null; } const src = streamUrl(itemId, { AudioStreamIndex: audioSelect.value, SubtitleStreamIndex: subtitleSelect.value, MaxStreamingBitrate: qualitySelect.value }); 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.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 //