watchparty/client/js/views/player.js
Nicolas b6818655b1
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s
Proxy all Emby access through our own backend; drop server-url field; use Emby's real theme colors
Three changes requested:

1. Server proxies Emby, browser never talks to it directly. Login is
   now POST /api/auth/login {username,password} on our own backend;
   the backend calls Emby's AuthenticateByName server-side and mints
   its own opaque session (httpOnly cookie), storing
   {embyToken,userId,userName} in memory. The browser never sees or
   holds a real Emby access token. Every other call - search, item
   details, images, HLS streaming, party actions - goes to our own
   /api/* origin, authenticated by that cookie; server/src/embyProxy.js
   forwards to Emby server-side and streams the response back
   (including a playlist-rewrite pass for HLS .m3u8 so hls.js never
   gets handed a URL pointing at Emby directly). This also sidesteps
   the CORS problem the previous direct-to-Emby design had.

2. EMBY_SERVER_URL is backend-only config (.env.example); the login
   form no longer asks for a server address at all.

3. client/css/app.css now uses colors read directly off the live Emby
   server's own dark theme (--theme-primary-color-* etc, fetched via a
   JS probe against the running client, not guessed): accent green
   #52b54b/#6ccf65, near-black background #151216.

Tested for real: ran the server locally against the actual
EMBY_SERVER_URL and submitted intentionally-wrong credentials through
the UI - confirmed the full round trip (browser -> our backend -> real
Emby -> real 401 -> our error message) with no CORS issue, since it's
all server-to-server now. Login itself hasn't been exercised with real
credentials, so catalog/streaming remain unverified - see
docs/architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:36:26 +02:00

102 lines
3.1 KiB
JavaScript

import { getItem, 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>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 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;
codeEl.textContent = party.joinCode;
video.controls = isHost;
followerNotice.classList.toggle('hide', isHost);
const item = await getItem(party.itemId);
const src = streamUrl(item.id);
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();
}