All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
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>
371 lines
15 KiB
JavaScript
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 →</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>
|
|
|
|
<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(() => {});
|
|
};
|
|
}
|