Fix tile sizing, move player options into a gear menu, surface player errors
All checks were successful
Publier l'image Docker / publish (push) Successful in 17s
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>
This commit is contained in:
parent
f563f5b0a2
commit
02587632ff
3 changed files with 135 additions and 26 deletions
|
|
@ -277,6 +277,11 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
/* Without this, a long title (or an image's intrinsic size before it
|
||||||
|
loads) can force this flex/grid item wider than its declared size -
|
||||||
|
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
|
||||||
|
stay exactly the track/flex-basis size regardless of content. */
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile img {
|
.tile img {
|
||||||
|
|
@ -437,19 +442,38 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
||||||
.overlay .itemTitle { font-weight: 600; }
|
.overlay .itemTitle { font-weight: 600; }
|
||||||
.overlay .members { color: var(--muted); flex: 1; }
|
.overlay .members { color: var(--muted); flex: 1; }
|
||||||
|
|
||||||
.hostControls {
|
/* Settings: a small gear icon in the action bar, not a permanently-visible
|
||||||
position: absolute;
|
block of dropdowns - opens a compact popup on click. */
|
||||||
top: 64px;
|
.settingsWrap { position: relative; }
|
||||||
right: 16px;
|
|
||||||
display: flex;
|
.iconBtn {
|
||||||
gap: 10px;
|
background: none;
|
||||||
background: rgba(0, 0, 0, .6);
|
padding: 4px 8px;
|
||||||
padding: 10px;
|
font-size: 16px;
|
||||||
border-radius: 10px;
|
line-height: 1;
|
||||||
font-size: 12px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hostControls label {
|
.iconBtn:hover { background: rgba(255, 255, 255, .12); }
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel label {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -457,7 +481,7 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hostControls select {
|
.settingsPanel select {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
|
|
@ -466,6 +490,30 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadingNotice {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerError {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(0, 0, 0, .75);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
|
||||||
|
|
||||||
.followerNotice {
|
.followerNotice {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
// inside #mainContent - see app.js's boot().
|
// inside #mainContent - see app.js's boot().
|
||||||
import { auth, logout, getLibraries } from './embyApi.js';
|
import { auth, logout, getLibraries } from './embyApi.js';
|
||||||
|
|
||||||
const COLLECTION_ICONS = { movies: '🎬', tvshows: '📺', music: '🎵' };
|
|
||||||
|
|
||||||
export async function renderShell(appEl) {
|
export async function renderShell(appEl) {
|
||||||
appEl.innerHTML = `
|
appEl.innerHTML = `
|
||||||
<div class="appShell">
|
<div class="appShell">
|
||||||
|
|
@ -37,7 +35,7 @@ export async function renderShell(appEl) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = `#/library/${lib.id}`;
|
a.href = `#/library/${lib.id}`;
|
||||||
a.className = 'navItem';
|
a.className = 'navItem';
|
||||||
a.textContent = `${COLLECTION_ICONS[lib.collectionType] || '📁'} ${lib.name}`;
|
a.textContent = lib.name;
|
||||||
nav.appendChild(a);
|
nav.appendChild(a);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -22,34 +22,60 @@ export function renderPartyPlayer(root, params) {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="playerWrap">
|
<div class="playerWrap">
|
||||||
<video id="video" playsinline></video>
|
<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">
|
<div class="overlay">
|
||||||
<span id="itemTitle" class="itemTitle"></span>
|
<span id="itemTitle" class="itemTitle"></span>
|
||||||
<span>Code: <span id="code" class="code"></span></span>
|
<span>Code: <span id="code" class="code"></span></span>
|
||||||
<span id="members" class="members"></span>
|
<span id="members" class="members"></span>
|
||||||
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
||||||
<button id="leaveBtn" class="linklike">Quitter</button>
|
|
||||||
</div>
|
<div id="settingsWrap" class="settingsWrap hide">
|
||||||
<div id="hostControls" class="hostControls 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>Langue <select id="audioSelect"></select></label>
|
||||||
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
||||||
<label>Qualité <select id="qualitySelect"></select></label>
|
<label>Qualité <select id="qualitySelect"></select></label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const video = root.querySelector('#video');
|
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 itemTitleEl = root.querySelector('#itemTitle');
|
||||||
const codeEl = root.querySelector('#code');
|
const codeEl = root.querySelector('#code');
|
||||||
const membersEl = root.querySelector('#members');
|
const membersEl = root.querySelector('#members');
|
||||||
const nextBtn = root.querySelector('#nextBtn');
|
const nextBtn = root.querySelector('#nextBtn');
|
||||||
const followerNotice = root.querySelector('#followerNotice');
|
const followerNotice = root.querySelector('#followerNotice');
|
||||||
const hostControls = root.querySelector('#hostControls');
|
const settingsWrap = root.querySelector('#settingsWrap');
|
||||||
|
const settingsBtn = root.querySelector('#settingsBtn');
|
||||||
|
const settingsPanel = root.querySelector('#settingsPanel');
|
||||||
const audioSelect = root.querySelector('#audioSelect');
|
const audioSelect = root.querySelector('#audioSelect');
|
||||||
const subtitleSelect = root.querySelector('#subtitleSelect');
|
const subtitleSelect = root.querySelector('#subtitleSelect');
|
||||||
const qualitySelect = root.querySelector('#qualitySelect');
|
const qualitySelect = root.querySelector('#qualitySelect');
|
||||||
|
|
||||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
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 isHost = false;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
|
|
@ -58,6 +84,17 @@ export function renderPartyPlayer(root, params) {
|
||||||
let heartbeat = null;
|
let heartbeat = null;
|
||||||
let hasNextQueued = false;
|
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) {
|
function applyRemoteState(msg) {
|
||||||
suppressLocalEvents = true;
|
suppressLocalEvents = true;
|
||||||
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
|
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
|
||||||
|
|
@ -83,12 +120,16 @@ export function renderPartyPlayer(root, params) {
|
||||||
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
||||||
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('');
|
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Non-fatal: playback still works with Emby's own defaults, just without a picker.
|
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) {
|
function loadVideo(itemId, resumeSeconds, wasPlaying) {
|
||||||
currentItemId = itemId;
|
currentItemId = itemId;
|
||||||
|
clearError();
|
||||||
|
loadingNotice.classList.remove('hide');
|
||||||
|
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hls = null;
|
hls = null;
|
||||||
|
|
@ -101,18 +142,33 @@ export function renderPartyPlayer(root, params) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const resume = () => {
|
const resume = () => {
|
||||||
|
loadingNotice.classList.add('hide');
|
||||||
if (resumeSeconds) video.currentTime = resumeSeconds;
|
if (resumeSeconds) video.currentTime = resumeSeconds;
|
||||||
if (wasPlaying) video.play().catch(() => {});
|
if (wasPlaying) video.play().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (window.Hls && window.Hls.isSupported()) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
hls = new window.Hls();
|
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.loadSource(src);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hls.once(window.Hls.Events.MANIFEST_PARSED, resume);
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
} else {
|
// Safari plays HLS natively - no hls.js involved, so wire the plain
|
||||||
|
// <video> element's own error event instead.
|
||||||
video.src = src;
|
video.src = src;
|
||||||
video.addEventListener('loadedmetadata', resume, { once: true });
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +180,14 @@ export function renderPartyPlayer(root, params) {
|
||||||
}
|
}
|
||||||
|
|
||||||
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
|
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
|
||||||
sel.addEventListener('change', reloadWithCurrentOptions);
|
sel.addEventListener('change', () => {
|
||||||
|
settingsPanel.classList.add('hide');
|
||||||
|
reloadWithCurrentOptions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector('#playerErrorRetry').addEventListener('click', () => {
|
||||||
|
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onItemChanged(msg) {
|
async function onItemChanged(msg) {
|
||||||
|
|
@ -149,7 +212,7 @@ export function renderPartyPlayer(root, params) {
|
||||||
itemTitleEl.textContent = party.itemName || '';
|
itemTitleEl.textContent = party.itemName || '';
|
||||||
codeEl.textContent = party.joinCode;
|
codeEl.textContent = party.joinCode;
|
||||||
video.controls = isHost;
|
video.controls = isHost;
|
||||||
hostControls.classList.toggle('hide', !isHost);
|
settingsWrap.classList.toggle('hide', !isHost);
|
||||||
followerNotice.classList.toggle('hide', isHost);
|
followerNotice.classList.toggle('hide', isHost);
|
||||||
hasNextQueued = !!party.hasNext;
|
hasNextQueued = !!party.hasNext;
|
||||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue