All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Three requested changes plus one found-in-passing bug fix: 1. Home page's "watch parties en cours" is now a live Socket.IO subscription (connectLobby -> "lobby" room), not a one-shot fetch - updates the instant anything changes anywhere (create/join/leave). Host name + member count come from the snapshot; elapsed time ticks purely client-side from a createdUtc timestamp (no point pushing a recomputed duration every second for every viewer). home.js returns this subscription from its router cleanup, same leak-prevention pattern as the player's socket. 2. Host handoff is now live end to end, not just correct in the data model. PartyManager.leave already promoted another member and destroyed an empty party (that part predates this change) - what was missing was any way for an already-open follower player to find out it became host. applyLeave (shared by the socket leave/ disconnect handlers and the REST leave endpoint, so this and the lobby broadcast only live in one place) now emits a personal "hostChanged" to the new host's own socket; player.js's becomeHost() unlocks controls live. becomeHost is written to tolerate being called more than once safely (addEventListener no-ops on a repeat identical listener; the heartbeat setInterval is explicitly guarded since it has no such idempotence - calling it twice would leak the first interval, the exact bug class the previous pass fixed). 3. No more join codes - PartyManager no longer generates or tracks one. The live lobby list is the only discovery mechanism now; a code copied out of band doesn't add anything on top of a list that's already always current. POST /api/parties/:partyId/join replaces :joinCode; the "Rejoindre avec un code" UI is gone. Found while touching this: the home page's "Rejoindre" button used to navigate to the player WITHOUT ever calling the join endpoint first - it loaded fine (GET /api/parties/:id never checked membership) but the realtime "join" was silently rejected server-side (not an actual member), so sync never worked for anyone joining this way. Fixed to await joinParty() before navigating, matching how the library grid's tiles already did it. Also: .playerWrap was width:100%/height:100vh inside .mainContent's own padding, so the two stacked and made the page taller than the viewport - an outer scrollbar despite the video already being full- height. Changed to position:fixed/inset:0 so it fills the real viewport regardless of ancestor padding. Verified live against a real running server (see session history): lobby subscription renders host/member-count/ticking-elapsed correctly from a faked "parties" push (createdUtc 2:05 in the past rendered "2:06" after ~1s more, proving the client-side tick is real, not static); the join-then-navigate fix calls the join endpoint before navigating; .playerWrap computes to position:fixed with all four insets at 0. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
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 = `
|
|
<div class="playerWrap">
|
|
<video id="video" playsinline></video>
|
|
|
|
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
|
|
<div id="playerError" class="playerError hide">
|
|
<p id="playerErrorText"></p>
|
|
<button id="playerErrorRetry" class="linklike">Réessayer</button>
|
|
</div>
|
|
|
|
<div class="overlay">
|
|
<span id="itemTitle" class="itemTitle"></span>
|
|
<span id="members" class="members"></span>
|
|
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
|
|
|
<div id="settingsWrap" class="settingsWrap hide">
|
|
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
|
<div id="settingsPanel" class="settingsPanel hide">
|
|
<label>Langue <select id="audioSelect"></select></label>
|
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
|
<label>Qualité <select id="qualitySelect"></select></label>
|
|
</div>
|
|
</div>
|
|
|
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
|
</div>
|
|
|
|
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
|
</div>`;
|
|
|
|
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 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) => `<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.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;
|
|
|
|
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) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
|
: '<option value="">Piste par défaut</option>';
|
|
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
|
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).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
|
|
// <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 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', () => {
|
|
settingsPanel.classList.add('hide');
|
|
reloadWithCurrentOptions();
|
|
});
|
|
});
|
|
|
|
root.querySelector('#playerErrorRetry').addEventListener('click', () => {
|
|
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
itemTitleEl.textContent = party.itemName || '';
|
|
hasNextQueued = !!party.hasNext;
|
|
followerNotice.classList.toggle('hide', party.youAreHost);
|
|
|
|
await loadTrackOptions(party.itemId);
|
|
loadVideo(party.itemId, 0, false);
|
|
|
|
conn = connectParty(partyId, {
|
|
onMembers: renderMembers,
|
|
onPlaystate: applyRemoteState,
|
|
onItemChanged,
|
|
onHostChanged: () => becomeHost(true),
|
|
onError: (message) => alert(message),
|
|
onClose: () => {}
|
|
});
|
|
|
|
if (party.youAreHost) becomeHost(false);
|
|
}
|
|
|
|
function renderMembers(members) {
|
|
membersEl.textContent = members
|
|
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
|
.join(', ');
|
|
}
|
|
|
|
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
|
|
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
|
|
|
init();
|
|
|
|
// Called by router.js 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 (hls) hls.destroy();
|
|
document.removeEventListener('click', onDocumentClick);
|
|
if (conn) conn.leave();
|
|
leaveParty(partyId).catch(() => {});
|
|
};
|
|
}
|