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