All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Two features requested together: a semi-transparent overlay showing who's
in the party, and a way for a non-host to ask the host to pause.
- server/src/socketHandler.js: new "requestPause" event (anyone, throttled
5s per member server-side via member.lastPauseRequestUtc - the real
guard, independent of whatever the client does) broadcasts
"pauseRequested" {userId, userName} to the whole party room, not just the
host, so everyone sees why playback is about to pause.
- client/js/party.js: connectParty() grows an onPauseRequested callback and
the returned connection a requestPause() method, same shape as the
existing next()/sendPlaystate().
- client/js/views/player.js: renderMembers() now also rebuilds a
#presenceOverlay of one circle per member (initials from userName,
colorForName() picks a deterministic background from a small palette by
hashing the name - same person, same color, no server-side assignment
needed). "Which circle is mine" compares userName against the auth
singleton embyApi.js already populates, matching how the rest of the
client avoids keying UI logic on userId directly. Your own circle, only
when you're not host, is clickable and calls requestPause() (client-side
cooldown too, purely to avoid a click that would visibly do nothing - the
server-side one is what actually matters). Receiving pauseRequested shows
a short auto-clearing banner and pulses the matching circle for everyone,
including the requester and the host.
- client/css/app.css: .presenceOverlay/.avatar (bottom-left, opacity .75 is
the "semi transparence" itself) and the clickable/sent/pulse states,
.pauseRequestBanner.
Verified with mocked members/pauseRequested Socket.IO events against the
real running local server (see session history): initials/colors render
correctly, the host's own circle never gets the clickable affordance,
rapid repeat clicks collapse to exactly one requestPause emission (three
synchronous clicks -> one new emitted event), a pauseRequested event
banners and pulses the right circle. Re-checked the pre-existing host path
(becomeHost, settings menu, next-episode button) still works unchanged -
this pass only touched renderMembers() and init()'s connectParty() call.
docs/architecture.md has the full writeup. Also fixed two roadmap
checkboxes in README.md that should have been checked off by the previous
commit (live lobby/host handoff/no join codes) but were missed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
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
|
|
|
|
// "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() || '?';
|
|
}
|
|
|
|
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 id="presenceOverlay" class="presenceOverlay"></div>
|
|
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></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 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) => `<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;
|
|
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) => `<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);
|
|
}
|
|
|
|
// Presence overlay: one semi-transparent circle per member, initials only
|
|
// (no photos - Emby accounts don't reliably have one). Your own circle
|
|
// doubles as the "request a pause" button when you're not the host - the
|
|
// host already has direct controls, so their own circle is just presence.
|
|
function renderMembers(members) {
|
|
membersEl.textContent = members
|
|
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
|
.join(', ');
|
|
|
|
presenceOverlay.innerHTML = members.map((m) => {
|
|
const isMe = m.userName === auth.userName;
|
|
const clickable = isMe && !m.isHost;
|
|
return `
|
|
<div
|
|
class="avatar${clickable ? ' avatarClickable' : ''}"
|
|
style="background:${colorForName(m.userName)}"
|
|
data-user-id="${escapeHtml(m.userId)}"
|
|
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}"
|
|
>${escapeHtml(initialsFor(m.userName))}</div>`;
|
|
}).join('');
|
|
|
|
if (isHost) return; // the host pauses directly, no need for the request affordance
|
|
const mine = presenceOverlay.querySelector('.avatarClickable');
|
|
if (mine) {
|
|
mine.addEventListener('click', () => {
|
|
const now = Date.now();
|
|
if (now < pauseRequestCooldownUntil || !conn) return;
|
|
pauseRequestCooldownUntil = now + PAUSE_REQUEST_COOLDOWN_MS;
|
|
conn.requestPause();
|
|
mine.classList.add('avatarSent');
|
|
setTimeout(() => mine.classList.remove('avatarSent'), PAUSE_REQUEST_COOLDOWN_MS);
|
|
});
|
|
}
|
|
}
|
|
|
|
function onPauseRequested(msg) {
|
|
pauseBanner.textContent = `${msg.userName} demande une pause`;
|
|
pauseBanner.classList.remove('hide');
|
|
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
|
pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), 4000);
|
|
|
|
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
|
|
if (avatar) {
|
|
avatar.classList.add('avatarPulse');
|
|
setTimeout(() => avatar.classList.remove('avatarPulse'), 2000);
|
|
}
|
|
}
|
|
|
|
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),
|
|
onPauseRequested,
|
|
onError: (message) => alert(message),
|
|
onClose: () => {}
|
|
});
|
|
|
|
if (party.youAreHost) becomeHost(false);
|
|
}
|
|
|
|
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 (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
|
if (hls) hls.destroy();
|
|
document.removeEventListener('click', onDocumentClick);
|
|
if (conn) conn.leave();
|
|
leaveParty(partyId).catch(() => {});
|
|
};
|
|
}
|