import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js'; import { getParty, leaveParty, connectParty } from '../party.js'; import { navigate } from '../router.js'; import { escapeHtml } from '../tiles.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' } ]; // Small deterministic palette so the same person always gets the same // avatar color across a session, without needing the server to assign one. const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db']; function colorForName(name) { let hash = 0; for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0; return AVATAR_COLORS[hash % AVATAR_COLORS.length]; } function initialsFor(name) { return (name || '').trim().slice(0, 2).toUpperCase() || '?'; } // 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 = ` `; export function renderPartyPlayer(root, params) { const partyId = params.id; 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 //