watchparty/client/js/views/player.js
Nicolas 02587632ff
All checks were successful
Publier l'image Docker / publish (push) Successful in 17s
Fix tile sizing, move player options into a gear menu, surface player errors
1. Carousel/grid tiles had inconsistent widths - classic flexbox/grid
   "min-width: auto" pitfall: a long title (or an image's intrinsic
   size before load) could force a tile wider than its declared
   flex-basis/track size. Fixed with min-width: 0 on .tile. Verified:
   three tiles (one with a deliberately very long title) now measure
   exactly 160px each.

2. Player language/subtitle/quality controls were three always-visible
   dropdowns floating over the video. Moved behind a small gear-icon
   button in the action bar that opens a compact popup panel
   (closes on outside click or on selecting an option) - host-only,
   same as before.

3. Sidebar library entries no longer get an emoji prefix - plain names.

4. Player failures were silent (a caught error with just a code
   comment, or hls.js failing with nothing shown). Added: a loading
   notice while the stream loads, an on-screen error box (with a
   Réessayer button) for fatal hls.js errors and native <video> errors,
   and console.error logging of the technical detail. Confirmed
   working live during testing - a real hls.js fatal error (no live
   Emby stream available in the test environment) was caught and
   displayed correctly instead of failing silently. This is meant to
   turn "the player doesn't work" into an actual diagnosable error
   message next time it happens for real.

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

257 lines
9.2 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>Code: <span id="code" class="code"></span></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>
<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 codeEl = root.querySelector('#code');
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('');
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPanel.classList.toggle('hide');
});
document.addEventListener('click', (e) => {
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
});
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 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 || !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) => `<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;
}
isHost = party.youAreHost;
itemTitleEl.textContent = party.itemName || '';
codeEl.textContent = party.joinCode;
video.controls = isHost;
settingsWrap.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();
}