watchparty/client/js/views/player.js
Nicolas f6a8010647
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Move presence overlay to the top, orange badge for pause requests, series detail page
Two rounds of feedback on the previous commit, plus a related feature
request:

1. Presence overlay moved from bottom-left to the top of the player.
   client/js/views/player.js: the top gradient bar is now .topOverlay, a
   column of two stacked rows - .actionBar (what used to be the whole bar:
   title/members-text/next/settings/leave) and #presenceOverlay directly
   below it. Stacking them in normal flow means the avatar row always
   lands right after the action bar regardless of how tall that bar
   renders (it wraps on narrow screens), instead of a second
   absolutely-positioned block at a guessed pixel offset.

2. "Someone requested a pause" is now a specific orange border
   (.avatarPauseRequested, #ff9800) plus a small pause-icon badge in the
   circle's corner (.avatarPauseBadge - a plain inline two-bar SVG, not a
   Unicode glyph, since pause/play characters default to emoji
   presentation on several platforms and look muddy at 10px) - replacing
   the previous generic box-shadow pulse. This also absorbed the separate
   "click feedback" ring that used to live only on the requester's own
   circle (.avatarSent): since the server already broadcasts
   "pauseRequested" back to the requester too, one state driven purely by
   that broadcast is simpler than two overlapping ones.

   Found while repositioning the banner above .followerNotice: the
   notice is a <p>, and for an absolutely positioned element "bottom"
   places the MARGIN edge, so its default ~1em user-agent margin was
   quietly rendering it 13px higher than its declared bottom:24px ever
   suggested. Gave it margin:0.

3. Series detail page (client/js/views/series.js): the page now opens
   with an actual detail header - poster, title + year, overview, "Voir
   sur Emby" - above the season tabs/episode list, instead of a bare <h1>.
   The season/episode browsing and per-episode "Lancer une watch party"
   already worked end to end (library grid -> Series tile -> this route);
   this pass only made the page look like a detail page. Reused
   toItemDto's existing fields (GET /api/catalog/item/:id), no backend
   changes needed. Widened .linklike's CSS selector from
   `button.linklike` to `.linklike` so the new "Voir sur Emby" <a> picks
   it up too - app.css has no global `a` reset, so an unclassed anchor
   would otherwise render with the browser's default blue/underline.

Verified with mocked Socket.IO events / API responses against the real
running local server (see session history): presence row renders directly
under the action bar; a pauseRequested event gives the right circle
rgb(255, 152, 0) as its computed border color and display:flex on its
badge while leaving every other circle untouched (checked via computed
styles, not just class names); rapid repeat clicks on your own circle
still collapse to exactly one requestPause emission; banner/followerNotice
gap measured 15px after the margin fix, versus 2px before it; the series
page's header renders correctly, season-tab switching swaps the episode
list, a tvshows library's Series tiles link to /series/:id via both the
poster and "Voir les épisodes", and starting an episode's party correctly
creates it and navigates to the player.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:22:07 +02:00

371 lines
15 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
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 = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`;
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="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</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>
<div id="presenceOverlay" class="presenceOverlay"></div>
</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.
// The pause-badge <span> is always in the markup, just hidden by
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
// thing that ever shows it.
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))}<span class="avatarPauseBadge">${PAUSE_ICON_SVG}</span></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();
});
}
}
// Broadcast to everyone in the party, including the requester and the
// host (see socketHandler.js) - so this always flags the right circle no
// matter whose screen it renders on.
function onPauseRequested(msg) {
pauseBanner.textContent = `${msg.userName} demande une pause`;
pauseBanner.classList.remove('hide');
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
if (avatar) {
avatar.classList.add('avatarPauseRequested');
setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS);
}
}
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(() => {});
};
}