All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).
Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
from GET /api/catalog/libraries which now also returns
collectionType) + a #mainContent the router swaps on navigation.
router.js made idempotent (start() safe to call more than once) so
a login -> shell boot can happen twice in one page lifetime (logout
then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
shell depending on session state; login.js calls it again on
success instead of navigate() (no router running yet at that point).
Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
server-wide, a join-by-code fallback, then one "recently added"
carousel per non-music library (GET /api/catalog/libraries/:id/recent,
Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
movies/tvshows get the tile grid (Series tiles link into /series/:id
instead of starting a party directly), music gets an honest
placeholder.
- views/series.js (route /series/:id): season tabs + episode list
(new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
/Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
they can't drift apart.
Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
(fetches the rest of the series, from that episode onward) -
partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
reloads the player against the new item. player.js shows "Épisode
suivant" when hasNext, and auto-sends next() on the video's `ended`
event.
Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
(host-only) that reload the stream at the same position on change.
Subtitles end up burned into the transcode (via SubtitleStreamIndex),
not a toggleable track - simpler than sidecar WebVTT for this pass.
"Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.
Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.
Removed client/js/views/browse.js, superseded by home.js + library.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
6.8 KiB
JavaScript
194 lines
6.8 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>
|
|
<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>
|
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
|
</div>
|
|
<div id="hostControls" class="hostControls 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>
|
|
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
|
</div>`;
|
|
|
|
const video = root.querySelector('#video');
|
|
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 hostControls = root.querySelector('#hostControls');
|
|
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('');
|
|
|
|
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 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) {
|
|
// Non-fatal: playback still works with Emby's own defaults, just without a picker.
|
|
}
|
|
}
|
|
|
|
function loadVideo(itemId, resumeSeconds, wasPlaying) {
|
|
currentItemId = itemId;
|
|
if (hls) {
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
|
|
const src = streamUrl(itemId, {
|
|
AudioStreamIndex: audioSelect.value,
|
|
SubtitleStreamIndex: subtitleSelect.value,
|
|
MaxStreamingBitrate: qualitySelect.value
|
|
});
|
|
|
|
const resume = () => {
|
|
if (resumeSeconds) video.currentTime = resumeSeconds;
|
|
if (wasPlaying) video.play().catch(() => {});
|
|
};
|
|
|
|
if (window.Hls && window.Hls.isSupported()) {
|
|
hls = new window.Hls();
|
|
hls.loadSource(src);
|
|
hls.attachMedia(video);
|
|
hls.once(window.Hls.Events.MANIFEST_PARSED, resume);
|
|
} else {
|
|
video.src = src;
|
|
video.addEventListener('loadedmetadata', resume, { once: true });
|
|
}
|
|
}
|
|
|
|
// 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', reloadWithCurrentOptions);
|
|
});
|
|
|
|
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;
|
|
hostControls.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();
|
|
}
|