All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
- New backend endpoints: GET /api/catalog/libraries (Emby's
/Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level,
Recursive=false - movies as movies, TV as Series tiles), GET
/api/parties (list every active party server-wide, for the home
page's "watch parties en cours" section)
- Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video
are directly streamable - a Series tile only gets "Voir sur Emby")
and `embyUrl`, a deep link into Emby's own web client built from
EMBY_SERVER_URL + serverId (now captured at login)
- Party creation now fetches and stores the item's name/poster so the
home page's party list doesn't need a separate lookup per party
- client/js/views/browse.js rewritten: ongoing-parties section, a
join-by-code fallback, one tab per library, and a tile grid with a
hover-revealed two-button overlay (start party / view on Emby) - the
first only shown for playable types
- Removed itemDetail.js/the /item/:id route (tiles act directly now,
no intermediate detail page); player.js simplified to use
party.itemName instead of a redundant item lookup
- app.css: full-width home layout (no max-width cap, as asked), CSS
grid handling all the responsive reflow, :hover/:focus-within reveal
for the tile overlay with an always-visible (hover: none) fallback
for touch devices
Verified by actually driving a browser (mocked fetch standing in for
/api/parties and /api/catalog/* - real login still needs real
credentials): tab switching swaps content correctly, the overlay
reveals per-tile only, a Series tile correctly omits the watch-party
button, and the touch fallback shows both buttons inline without
hover. See docs/architecture.md for exactly what that does and doesn't
confirm (real Emby /Views and item-list response shapes are still
unverified).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
3.2 KiB
JavaScript
104 lines
3.2 KiB
JavaScript
import { streamUrl } from '../embyApi.js';
|
|
import { getParty, leaveParty, connectParty } from '../party.js';
|
|
import { navigate } from '../router.js';
|
|
|
|
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
|
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
|
|
|
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="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 itemTitleEl = root.querySelector('#itemTitle');
|
|
const codeEl = root.querySelector('#code');
|
|
const membersEl = root.querySelector('#members');
|
|
const followerNotice = root.querySelector('#followerNotice');
|
|
|
|
let isHost = false;
|
|
let ws = null;
|
|
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
|
|
let heartbeat = null;
|
|
|
|
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 init() {
|
|
let party;
|
|
try {
|
|
party = await getParty(partyId);
|
|
} catch (err) {
|
|
alert(err.message);
|
|
navigate('/browse');
|
|
return;
|
|
}
|
|
|
|
isHost = party.youAreHost;
|
|
itemTitleEl.textContent = party.itemName || '';
|
|
codeEl.textContent = party.joinCode;
|
|
video.controls = isHost;
|
|
followerNotice.classList.toggle('hide', isHost);
|
|
|
|
const src = streamUrl(party.itemId);
|
|
if (window.Hls && window.Hls.isSupported()) {
|
|
const hls = new window.Hls();
|
|
hls.loadSource(src);
|
|
hls.attachMedia(video);
|
|
} else {
|
|
video.src = src;
|
|
}
|
|
|
|
ws = connectParty(partyId, {
|
|
onMembers: renderMembers,
|
|
onPlaystate: applyRemoteState,
|
|
onError: (message) => alert(message),
|
|
onClose: () => {}
|
|
});
|
|
|
|
if (isHost) {
|
|
video.addEventListener('play', sendLocalState);
|
|
video.addEventListener('pause', sendLocalState);
|
|
video.addEventListener('seeked', sendLocalState);
|
|
heartbeat = setInterval(sendLocalState, 3000);
|
|
}
|
|
}
|
|
|
|
function renderMembers(members) {
|
|
membersEl.textContent = members
|
|
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
|
.join(', ');
|
|
}
|
|
|
|
root.querySelector('#leaveBtn').addEventListener('click', async () => {
|
|
if (heartbeat) clearInterval(heartbeat);
|
|
if (ws) ws.leave();
|
|
await leaveParty(partyId).catch(() => {});
|
|
navigate('/browse');
|
|
});
|
|
|
|
init();
|
|
}
|