import { streamUrl, getPlaybackOptions, auth } 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;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
// "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' }
];
// Small deterministic palette so the same person always gets the same
// avatar color across a session, without needing the server to assign one.
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
function colorForName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
function initialsFor(name) {
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
}
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
// specifically so this small a badge stays a crisp two-bar shape regardless
// of the platform's emoji font - pause/play glyphs default to emoji
// presentation on several platforms, which looks muddy at 10px.
const PAUSE_ICON_SVG = `
`;
export function renderPartyPlayer(root, params) {
const partyId = params.id;
root.innerHTML = `
Chargement du flux...
L'hôte contrôle la lecture.
`;
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 membersEl = root.querySelector('#members');
const presenceOverlay = root.querySelector('#presenceOverlay');
const pauseBanner = root.querySelector('#pauseRequestBanner');
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) => ``).join('');
// Named (not inline-anonymous) specifically so cleanup() below can remove
// this exact listener - document.addEventListener is a page-lifetime
// target, so an anonymous one added on every visit to this route would
// never be reachable to remove, and would pile up across visits. This was
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
// used to leave one of these (plus the heartbeat interval, the socket,
// and the hls.js instance) running forever - see router.js's cleanup hook.
function onDocumentClick(e) {
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
}
document.addEventListener('click', onDocumentClick);
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPanel.classList.toggle('hide');
});
let isHost = false;
let hasNextQueued = false;
let conn = null;
let hls = null;
let currentItemId = null;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat = null;
let pauseRequestCooldownUntil = 0;
let pauseBannerTimer = null;
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 || !conn) return;
conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
}
/**
* Promotes this client to host - called either at init (if we already
* were) or on a live "hostChanged" event (the previous host left and the
* server picked us next - see socketHandler.js's applyLeave). Safe to
* call more than once: the early return covers the normal case, and
* addEventListener with the same function reference is a no-op if already
* attached, so even a re-entrant call can't double up the play/pause/
* seeked listeners. The heartbeat is guarded separately since setInterval
* has no such built-in idempotence - calling it twice would leak the
* first interval's id.
*/
function becomeHost(notify) {
if (isHost) return;
isHost = true;
video.controls = true;
settingsWrap.classList.remove('hide');
followerNotice.classList.add('hide');
nextBtn.classList.toggle('hide', !hasNextQueued);
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
video.addEventListener('seeked', sendLocalState);
video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); });
if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000);
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
async function loadTrackOptions(itemId) {
try {
const options = await getPlaybackOptions(itemId);
audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => ``).join('')
: '';
subtitleSelect.innerHTML = '' +
options.subtitleTracks.map((t) => ``).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
//