import { streamUrl, getPlaybackOptions } 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; // "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' } ]; export function renderPartyPlayer(root, params) { const partyId = params.id; root.innerHTML = `
Code:

L'hôte contrôle la lecture.

`; const video = root.querySelector('#video'); const itemTitleEl = root.querySelector('#itemTitle'); const codeEl = root.querySelector('#code'); const membersEl = root.querySelector('#members'); const nextBtn = root.querySelector('#nextBtn'); const followerNotice = root.querySelector('#followerNotice'); const hostControls = root.querySelector('#hostControls'); const audioSelect = root.querySelector('#audioSelect'); const subtitleSelect = root.querySelector('#subtitleSelect'); const qualitySelect = root.querySelector('#qualitySelect'); qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).join(''); let isHost = false; let ws = null; let hls = null; let currentItemId = null; let suppressLocalEvents = false; // avoid feeding our own correction back as a new event let heartbeat = null; let hasNextQueued = false; 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 || !ws) return; ws.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND)); } 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) { // Non-fatal: playback still works with Emby's own defaults, just without a picker. } } function loadVideo(itemId, resumeSeconds, wasPlaying) { currentItemId = itemId; if (hls) { hls.destroy(); hls = null; } const src = streamUrl(itemId, { AudioStreamIndex: audioSelect.value, SubtitleStreamIndex: subtitleSelect.value, MaxStreamingBitrate: qualitySelect.value }); const resume = () => { if (resumeSeconds) video.currentTime = resumeSeconds; if (wasPlaying) video.play().catch(() => {}); }; if (window.Hls && window.Hls.isSupported()) { hls = new window.Hls(); hls.loadSource(src); hls.attachMedia(video); hls.once(window.Hls.Events.MANIFEST_PARSED, resume); } else { video.src = src; video.addEventListener('loadedmetadata', resume, { once: true }); } } // Changing language/subtitles/quality means a fresh transcode (Emby bakes // the selection in server-side - see index.js) - reload at the same // position instead of restarting from zero. function reloadWithCurrentOptions() { loadVideo(currentItemId, video.currentTime, !video.paused); } [audioSelect, subtitleSelect, qualitySelect].forEach((sel) => { sel.addEventListener('change', reloadWithCurrentOptions); }); async function onItemChanged(msg) { itemTitleEl.textContent = msg.itemName || ''; hasNextQueued = !!msg.hasNext; nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); await loadTrackOptions(msg.itemId); loadVideo(msg.itemId, 0, true); } async function init() { let party; try { party = await getParty(partyId); } catch (err) { alert(err.message); navigate('/home'); return; } isHost = party.youAreHost; itemTitleEl.textContent = party.itemName || ''; codeEl.textContent = party.joinCode; video.controls = isHost; hostControls.classList.toggle('hide', !isHost); followerNotice.classList.toggle('hide', isHost); hasNextQueued = !!party.hasNext; nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); await loadTrackOptions(party.itemId); loadVideo(party.itemId, 0, false); ws = connectParty(partyId, { onMembers: renderMembers, onPlaystate: applyRemoteState, onItemChanged, onError: (message) => alert(message), onClose: () => {} }); if (isHost) { video.addEventListener('play', sendLocalState); video.addEventListener('pause', sendLocalState); video.addEventListener('seeked', sendLocalState); video.addEventListener('ended', () => { if (hasNextQueued) ws.next(); }); heartbeat = setInterval(sendLocalState, 3000); } } function renderMembers(members) { membersEl.textContent = members .map((m) => m.userName + (m.isHost ? ' (hôte)' : '')) .join(', '); } nextBtn.addEventListener('click', () => { if (ws) ws.next(); }); root.querySelector('#leaveBtn').addEventListener('click', async () => { if (heartbeat) clearInterval(heartbeat); if (hls) hls.destroy(); if (ws) ws.leave(); await leaveParty(partyId).catch(() => {}); navigate('/home'); }); init(); }