feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m57s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m18s

This commit is contained in:
2026-09-23 10:41:15 -04:00
parent e2417cb5ab
commit e3c6789776
21 changed files with 1738 additions and 211 deletions
+980
View File
@@ -0,0 +1,980 @@
/* ObsiGate — Global "Now Playing" media controller (roadmap #110).
*
* Owns a SINGLE <audio>/<video> element for the whole application and teleports
* it between two surfaces:
* - inline: inside the media's own tab/pane view (native controls);
* - dock: a body-level floating surface (audio pill / video mini-window)
* that keeps the media playing while the user navigates.
*
* Playback is therefore never interrupted by tab/pane re-renders. The module
* also wires the Media Session API (lock screen / hardware keys / OS overlay),
* native Picture-in-Picture for video, folder prev/next, and resumable state
* across reloads.
*
* The module is deliberately self-contained: it imports only i18n + low-level
* utils and reaches the tab system through the `window.*` globals exposed by
* ui.js / pane-manager.js (no import cycle).
*/
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
const LS_KEY = 'obsigate-now-playing';
const LS_PREFS = 'obsigate-player-prefs';
const LS_POS = 'obsigate-player-pos';
const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.oga', '.opus', '.flac']);
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.m4v']);
/** Format a duration in seconds as ``m:ss`` (or ``h:mm:ss``). */
export function formatMediaDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Build the byte-range streaming URL used by the shared media element. */
export function buildMediaUrl(vault, path) {
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function extOf(p) {
const lower = (p || '').toLowerCase();
const dot = lower.lastIndexOf('.');
return dot === -1 ? '' : lower.slice(dot);
}
/** True when *p* is a supported audio path. */
export function isAudioPath(p) {
return AUDIO_EXTS.has(extOf(p));
}
/** True when *p* is a supported video path. */
export function isVideoPath(p) {
return VIDEO_EXTS.has(extOf(p));
}
function downloadUrl(vault, path) {
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
}
// ── Tiny DOM helpers (no dependency on viewer.js, which imports this module) ──
function npEl(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
if (v === false || v == null) return;
if (k === 'class') e.className = v;
else e.setAttribute(k, v);
});
}
if (children) children.forEach((c) => { if (c) e.appendChild(c); });
return e;
}
function npIcon(name, size) {
const i = document.createElement('i');
i.setAttribute('data-lucide', name);
i.style.width = size + 'px';
i.style.height = size + 'px';
i.classList.add('icon');
return i;
}
function setIcon(btn, name) {
if (!btn) return;
const i = btn.querySelector('i');
if (i) i.setAttribute('data-lucide', name);
}
function notify(message, type = 'info') {
if (typeof window._obsigateShowToast === 'function') window._obsigateShowToast(message, type);
}
function isMobile() {
try { return window.matchMedia('(max-width: 768px)').matches; } catch (_) { return false; }
}
// ── Persistent prefs (volume / rate) ──────────────────────────────────────────
function readPrefs() {
try { return JSON.parse(localStorage.getItem(LS_PREFS) || '{}') || {}; } catch (_) { return {}; }
}
function writePrefs(patch) {
try { localStorage.setItem(LS_PREFS, JSON.stringify({ ...readPrefs(), ...patch })); } catch (_) { /* quota */ }
}
function readPosition() {
try { return JSON.parse(localStorage.getItem(LS_POS) || 'null'); } catch (_) { return null; }
}
function writePosition(pos) {
try { localStorage.setItem(LS_POS, JSON.stringify(pos)); } catch (_) { /* quota */ }
}
// ── Controller state ──────────────────────────────────────────────────────────
const S = {
active: false,
vault: null,
path: null,
title: '',
kind: null, // 'audio' | 'video'
streamUrl: '',
mime: '',
sizeBytes: 0,
media: null,
inline: false,
expanded: false,
siblings: [],
index: -1,
host: null,
dock: null,
dockKind: null,
expandedEl: null,
mediaSlotInline: null,
_saveTimer: null,
_drag: null,
};
// ── Media element lifecycle ───────────────────────────────────────────────────
function applyPrefsToMedia(media) {
const p = readPrefs();
if (typeof p.volume === 'number') media.volume = Math.min(1, Math.max(0, p.volume));
if (typeof p.muted === 'boolean') media.muted = p.muted;
if (typeof p.rate === 'number') media.playbackRate = p.rate;
}
function ensureMedia(kind) {
if (S.media && S.media.dataset.npKind === kind) return S.media;
if (S.media) destroyMedia();
const media = document.createElement(kind);
media.className = `np-media np-media--${kind}`;
media.dataset.npKind = kind;
media.setAttribute('controls', '');
media.setAttribute('preload', 'metadata');
media.setAttribute('playsinline', '');
media.setAttribute('aria-label', t('player.now_playing'));
attachMediaListeners(media);
applyPrefsToMedia(media);
S.media = media;
return media;
}
function destroyMedia() {
const m = S.media;
if (!m) return;
try { m.pause(); } catch (_) { /* detached */ }
m.removeAttribute('src');
try { m.load(); } catch (_) { /* ignore */ }
m.remove();
S.media = null;
}
function attachMediaListeners(media) {
media.addEventListener('loadedmetadata', () => {
if (Number.isFinite(media.duration)) updateAll();
});
media.addEventListener('timeupdate', () => {
updateProgress();
schedulePersist();
});
media.addEventListener('play', () => {
setIcon(S.dock && S.dock.querySelector('[data-np="play"]'), 'pause');
syncSessionPlayback();
persist();
});
media.addEventListener('pause', () => {
setIcon(S.dock && S.dock.querySelector('[data-np="play"]'), 'play');
syncSessionPlayback();
persist();
});
media.addEventListener('ended', () => {
if (!goSibling(1, { fromEnded: true })) { persist(); }
});
media.addEventListener('volumechange', () => {
writePrefs({ volume: media.volume, muted: media.muted });
updateVolumeInputs();
});
media.addEventListener('ratechange', () => {
writePrefs({ rate: media.playbackRate });
updateRateInputs();
});
media.addEventListener('error', () => onMediaError());
media.addEventListener('enterpictureinpicture', () => updatePipButton(true));
media.addEventListener('leavepictureinpicture', () => updatePipButton(false));
}
function onMediaError() {
if (!S.active) return;
const data = { vault: S.vault, path: S.path, title: S.title, stream_url: S.streamUrl, size_bytes: S.sizeBytes };
const message = t('viewer.media_unsupported');
if (S.inline && S.mediaSlotInline && S.mediaSlotInline.isConnected) {
const area = S.mediaSlotInline.closest('.audio-viewer-container, .video-viewer-container')?.parentElement;
renderFallback(area || S.mediaSlotInline.parentElement, data, message);
S.inline = false;
}
// Keep the dock but show a compact error state.
S.active = false;
clearPersisted();
if (S.dock) renderDockError(message);
}
/** Standalone fallback (unsupported codec / too large) used inline + in the dock. */
export function renderFallback(container, data, message) {
if (!container) return;
const sizeStr = formatBytes(data.size_bytes);
container.innerHTML = '';
const box = npEl('div', { class: 'unsupported-file' }, [
npIcon('file', 48),
npEl('div', { class: 'filename' }, [document.createTextNode((data.path || '').split('/').pop())]),
npEl('div', {}, [document.createTextNode(message)]),
sizeStr ? npEl('div', { style: 'font-size:0.85rem;margin-top:4px' }, [document.createTextNode(`${t('viewer.metadata_size')} : ${sizeStr}`)]) : null,
]);
const dl = npEl('button', { class: 'btn-action', type: 'button' }, [npIcon('download', 14), document.createTextNode(' ' + t('viewer.download'))]);
dl.addEventListener('click', () => window.open(downloadUrl(data.vault, data.path), '_blank'));
const open = npEl('button', { class: 'btn-action', type: 'button' }, [npIcon('external-link', 14), document.createTextNode(' ' + t('viewer.image_open_original'))]);
open.addEventListener('click', () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), '_blank'));
box.appendChild(npEl('div', { class: 'media-fallback-actions' }, [dl, open]));
container.appendChild(box);
safeCreateIcons();
}
function formatBytes(n) {
if (!n && n !== 0) return '';
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
// ── Loading a media into the shared element ───────────────────────────────────
function load(data, { autoplay = true } = {}) {
const kind = data.is_audio ? 'audio' : 'video';
const sameKey = S.media && S.media.dataset.npKey === `${data.vault}::${data.path}`;
S.vault = data.vault;
S.path = data.path;
S.title = data.title || (data.path || '').split('/').pop();
S.kind = kind;
S.streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
S.mime = data.media_mime || '';
S.sizeBytes = data.size_bytes || 0;
const media = ensureMedia(kind);
if (!sameKey) {
media.dataset.npKey = `${data.vault}::${data.path}`;
media.src = S.streamUrl;
}
S.active = true;
loadSiblings();
updateSessionMetadata();
if (autoplay && media.paused) {
const p = media.play();
if (p && p.catch) p.catch(() => { /* autoplay blocked — user presses play */ });
}
updateAll();
persist();
return media;
}
// ── Inline surface (the media's own tab/pane) ────────────────────────────────
function buildInline(data) {
const kind = S.kind;
const badge = npEl('span', { class: 'media-duration-badge' }, [document.createTextNode('--:--')]);
const vaultLabel = npEl('span', { class: 'media-meta' }, [document.createTextNode(`${data.vault} · ${formatBytes(S.sizeBytes)}`)]);
const toolbar = buildToolbar(data, [vaultLabel, badge]);
const container = npEl('div', { class: kind === 'audio' ? 'audio-viewer-container' : 'video-viewer-container' });
container.appendChild(toolbar);
let stage;
if (kind === 'audio') {
stage = npEl('div', { class: 'audio-stage' });
stage.appendChild(npEl('div', { class: 'audio-artwork' }, [npIcon('audio-lines', 64)]));
} else {
stage = npEl('div', { class: 'video-stage' });
}
const slot = npEl('div', { class: 'np-inline-slot' });
stage.appendChild(slot);
container.appendChild(stage);
return { container, slot, badge };
}
function buildToolbar(data, extra) {
const toolbar = npEl('div', { class: 'media-toolbar' });
toolbar.appendChild(npEl('span', { class: 'media-title', title: data.path }, [document.createTextNode(S.title)]));
toolbar.appendChild(npEl('div', { class: 'media-toolbar-spacer' }));
(extra || []).forEach((b) => toolbar.appendChild(b));
const open = npEl('button', { class: 'btn-action', type: 'button', title: t('viewer.image_open_original'), 'aria-label': t('viewer.image_open_original') }, [npIcon('external-link', 14)]);
open.addEventListener('click', () => window.open(S.streamUrl, '_blank'));
const dl = npEl('button', { class: 'btn-action', type: 'button', title: t('viewer.download'), 'aria-label': t('viewer.download') }, [npIcon('download', 14)]);
dl.addEventListener('click', () => window.open(downloadUrl(S.vault, S.path), '_blank'));
toolbar.appendChild(open);
toolbar.appendChild(dl);
return toolbar;
}
function attachInline(area, data) {
if (!area) return;
load(data, { autoplay: true });
const media = S.media;
const { container, slot, badge } = buildInline(data);
// Move the shared element into the (fresh) inline slot — no restart.
media.setAttribute('controls', '');
slot.appendChild(media);
area.innerHTML = '';
area.appendChild(container);
S.inline = true;
S.mediaSlotInline = slot;
hideDock();
if (Number.isFinite(media.duration)) badge.textContent = formatMediaDuration(media.duration);
media.addEventListener('loadedmetadata', function onMeta() {
if (badge && badge.isConnected) badge.textContent = formatMediaDuration(media.duration);
media.removeEventListener('loadedmetadata', onMeta);
});
updateAll();
safeCreateIcons();
}
/** If the shared media element lives inside *area*, move it out to the dock. */
function handleRender(area, data) {
if (!S.active || !S.media) return;
if (data && S.vault === data.vault && S.path === data.path) return; // own view attaches inline
if (S.inline && area && area.contains(S.media)) { showDock(); return; }
if (!S.media.isConnected) { showDock(); }
}
// ── Dock surface ──────────────────────────────────────────────────────────────
function ensureHost() {
if (S.host) return S.host;
const host = npEl('div', { id: 'now-playing-host', class: 'np-host' });
host.hidden = true;
document.body.appendChild(host);
S.host = host;
return host;
}
function stopEvent(e) { e.stopPropagation(); }
function ctrlButton(name, iconName, label, handler) {
const b = npEl('button', { class: 'np-ctrl', type: 'button', 'data-np': name, title: label, 'aria-label': label }, [npIcon(iconName, 16)]);
b.addEventListener('click', (e) => { stopEvent(e); handler(); });
return b;
}
function buildAudioDock() {
const dock = npEl('div', { class: 'np-dock np-dock--audio', role: 'region', 'aria-label': t('player.now_playing') });
const art = npEl('button', { class: 'np-dock-art', type: 'button', title: t('player.expand'), 'aria-label': t('player.expand') }, [npIcon('audio-lines', 22)]);
art.addEventListener('click', (e) => { stopEvent(e); toggleExpanded(); });
dock.appendChild(art);
const info = npEl('div', { class: 'np-dock-info' });
info.appendChild(npEl('span', { class: 'np-dock-title' }));
info.appendChild(npEl('span', { class: 'np-dock-sub' }));
dock.appendChild(info);
const controls = npEl('div', { class: 'np-dock-controls' });
controls.appendChild(ctrlButton('prev', 'skip-back', t('player.previous'), () => goSibling(-1)));
controls.appendChild(ctrlButton('play', 'play', t('player.play'), togglePlay));
controls.appendChild(ctrlButton('next', 'skip-forward', t('player.next'), () => goSibling(1)));
dock.appendChild(controls);
const progress = npEl('div', { class: 'np-dock-progress' });
const cur = npEl('span', { class: 'np-time np-time-cur' }, [document.createTextNode('0:00')]);
const seek = npEl('input', { class: 'np-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek'), 'data-np': 'seek' });
bindSeek(seek);
const dur = npEl('span', { class: 'np-time np-time-dur' }, [document.createTextNode('0:00')]);
progress.appendChild(cur);
progress.appendChild(seek);
progress.appendChild(dur);
dock.appendChild(progress);
const actions = npEl('div', { class: 'np-dock-actions' });
const volWrap = npEl('div', { class: 'np-volume' });
volWrap.appendChild(npIcon('volume-2', 16));
const vol = npEl('input', { class: 'np-volume-range', type: 'range', min: '0', max: '1', step: '0.05', value: '1', 'aria-label': t('player.volume'), 'data-np': 'volume' });
vol.addEventListener('input', (e) => { stopEvent(e); if (S.media) { S.media.volume = Number(vol.value); S.media.muted = false; } });
volWrap.appendChild(vol);
actions.appendChild(volWrap);
actions.appendChild(ctrlButton('reopen', 'maximize-2', t('player.reopen_tab'), focus));
actions.appendChild(ctrlButton('expand', 'chevron-up', t('player.expand'), toggleExpanded));
actions.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
dock.appendChild(actions);
return dock;
}
function buildVideoDock() {
const dock = npEl('div', { class: 'np-dock np-dock--video', role: 'region', 'aria-label': t('player.now_playing') });
const frame = npEl('div', { class: 'np-dock-video-frame' });
const slot = npEl('div', { class: 'np-dock-video-slot', 'data-np': 'slot' });
const bar = npEl('div', { class: 'np-dock-video-bar' });
const grip = npEl('span', { class: 'np-video-grip', title: t('player.move') }, [npIcon('grip-horizontal', 14)]);
bar.appendChild(grip);
bar.appendChild(ctrlButton('play', 'play', t('player.play'), togglePlay));
const seek = npEl('input', { class: 'np-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek'), 'data-np': 'seek' });
bindSeek(seek);
bar.appendChild(seek);
const time = npEl('span', { class: 'np-time np-time-cur' }, [document.createTextNode('0:00')]);
bar.appendChild(time);
bar.appendChild(ctrlButton('pip', 'picture-in-picture-2', t('player.pip'), togglePip));
bar.appendChild(ctrlButton('expand', 'maximize-2', t('player.reopen_tab'), focus));
bar.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
frame.appendChild(slot);
frame.appendChild(bar);
dock.appendChild(frame);
const resize = npEl('div', { class: 'np-video-resize', title: t('player.resize'), 'aria-hidden': 'true' });
dock.appendChild(resize);
bindVideoDrag(dock, grip);
bindVideoResize(dock, resize);
return dock;
}
function buildDock() {
ensureHost();
const kind = S.kind;
if (S.dock && S.dockKind === kind) return S.dock;
if (S.dock) S.dock.remove();
S.dock = kind === 'audio' ? buildAudioDock() : buildVideoDock();
S.dockKind = kind;
S.host.appendChild(S.dock);
applyVideoGeometry();
bindDelegatedControls();
return S.dock;
}
function bindDelegatedControls() {
if (!S.dock || S.dock._npBound) return;
S.dock._npBound = true;
}
function showDock() {
if (!S.active || !S.media) return;
const dock = buildDock();
const slot = dock.querySelector('[data-np="slot"]');
if (S.kind === 'video' && slot) {
S.media.setAttribute('controls', '');
slot.appendChild(S.media);
} else {
// Audio: element stays hidden (wrapper), custom pill drives it.
dock.appendChild(S.media);
}
S.inline = false;
S.mediaSlotInline = null;
updateAll();
if (S.host) S.host.hidden = false;
dock.hidden = false;
document.body.classList.add('np-active');
safeCreateIcons();
}
function hideDock() {
if (S.dock) S.dock.hidden = true;
if (S.host) S.host.hidden = true;
document.body.classList.remove('np-active');
if (S.expanded) closeExpanded();
}
function renderDockError(message) {
if (!S.dock) return;
const dock = S.dock;
dock.classList.add('np-dock--error');
const slot = dock.querySelector('[data-np="slot"]');
if (slot) {
slot.innerHTML = '';
slot.appendChild(npEl('div', { class: 'np-dock-error-msg' }, [document.createTextNode(message)]));
}
}
// ── Progress / seek ───────────────────────────────────────────────────────────
function bindSeek(input) {
let scrubbing = false;
input.addEventListener('pointerdown', () => { scrubbing = true; });
input.addEventListener('pointerup', () => { scrubbing = false; });
input.addEventListener('input', (e) => {
stopEvent(e);
if (!S.media) return;
const d = S.media.duration;
if (Number.isFinite(d) && d > 0) {
S.media.currentTime = (Number(input.value) / 1000) * d;
updateProgress();
}
});
input.addEventListener('change', () => { scrubbing = false; });
void scrubbing;
}
function updateProgress() {
const media = S.media;
if (!media) return;
const d = Number.isFinite(media.duration) ? media.duration : 0;
const cur = media.currentTime || 0;
const pct = d > 0 ? Math.round((cur / d) * 1000) : 0;
const curText = formatMediaDuration(cur) || '0:00';
const durText = formatMediaDuration(d) || '0:00';
document.querySelectorAll('.np-seek').forEach((el) => {
if (el === document.activeElement) return;
el.value = String(pct);
});
document.querySelectorAll('.np-time-cur').forEach((el) => { el.textContent = curText; });
document.querySelectorAll('.np-time-dur').forEach((el) => { el.textContent = durText; });
}
function updateVolumeInputs() {
if (!S.media) return;
document.querySelectorAll('.np-volume-range').forEach((el) => { el.value = String(S.media.muted ? 0 : S.media.volume); });
}
function updateRateInputs() {
if (!S.media) return;
document.querySelectorAll('.np-rate').forEach((el) => { el.value = String(S.media.playbackRate); });
}
function selfDurationLabel() {
return S.media && Number.isFinite(S.media.duration) ? formatMediaDuration(S.media.duration) : '';
}
function updateAll() {
const dock = S.dock;
if (dock) {
const title = dock.querySelector('.np-dock-title');
if (title) title.textContent = S.title;
const sub = dock.querySelector('.np-dock-sub');
if (sub) sub.textContent = S.kind === 'audio' ? `${S.vault}` : `${S.vault} · ${selfDurationLabel()}`.trim();
setIcon(dock.querySelector('[data-np="play"]'), S.media && !S.media.paused ? 'pause' : 'play');
const prev = dock.querySelector('[data-np="prev"]');
const next = dock.querySelector('[data-np="next"]');
const multi = S.siblings.length > 1;
if (prev) prev.disabled = !multi;
if (next) next.disabled = !multi;
const pip = dock.querySelector('[data-np="pip"]');
if (pip) pip.hidden = !supportsPip();
}
updateProgress();
updateVolumeInputs();
updateRateInputs();
updatePipButton(!!(document.pictureInPictureElement));
updateExpanded();
}
function togglePlay() {
if (!S.media) return;
if (S.media.paused) {
const p = S.media.play();
if (p && p.catch) p.catch(() => { /* blocked */ });
} else {
S.media.pause();
}
}
// ── Prev / next within the media folder ───────────────────────────────────────
async function loadSiblings() {
try {
const dir = S.path.includes('/') ? S.path.slice(0, S.path.lastIndexOf('/')) : '';
const res = await fetch(`/api/browse/${encodeURIComponent(S.vault)}?path=${encodeURIComponent(dir)}`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const want = S.kind;
S.siblings = (data.items || [])
.filter((it) => it.type === 'file')
.filter((it) => (want === 'audio' ? isAudioPath(it.path) : isVideoPath(it.path)));
S.index = S.siblings.findIndex((it) => it.path === S.path);
updateAll();
} catch (_) { /* best effort */ }
}
function goSibling(delta) {
if (S.siblings.length < 2 || S.index < 0) return false;
const next = (S.index + delta + S.siblings.length) % S.siblings.length;
openInApp(S.vault, S.siblings[next].path);
return true;
}
function openInApp(vault, path) {
const tm = (window.getActiveTabManager && window.getActiveTabManager()) || window.TabManager;
if (tm && typeof tm.open === 'function') {
Promise.resolve(tm.open(vault, path)).catch(() => { /* ignore */ });
return true;
}
if (window.Viewer && typeof window.Viewer.openFile === 'function') {
window.Viewer.openFile(vault, path);
return true;
}
return false;
}
// ── Public actions ────────────────────────────────────────────────────────────
export function focusNowPlaying() {
if (!S.active) return;
openInApp(S.vault, S.path);
}
const focus = focusNowPlaying;
/** Notify (once) that the media's tab was closed while playback continues. */
function notifyTabClosed(vault, path) {
if (S.active && S.vault === vault && S.path === path) notify(t('player.continues'), 'info');
}
export function stopNowPlaying() {
if (S.media) { try { S.media.pause(); } catch (_) { /* detached */ } }
S.active = false;
S.inline = false;
S.mediaSlotInline = null;
hideDock();
clearPersisted();
destroyMedia();
S.vault = S.path = null;
S.title = '';
S.kind = null;
S.siblings = [];
S.index = -1;
clearSession();
}
const stop = stopNowPlaying;
function toggleExpanded() {
if (S.expanded) closeExpanded();
else openExpanded();
}
function buildExpanded() {
const root = npEl('div', { class: 'np-expanded', role: 'dialog', 'aria-label': t('player.now_playing') });
const backdrop = npEl('div', { class: 'np-expanded-backdrop' });
backdrop.addEventListener('click', closeExpanded);
const card = npEl('div', { class: 'np-exp-card' });
card.appendChild(npEl('div', { class: 'np-exp-art' }, [npIcon('audio-lines', 56)]));
card.appendChild(npEl('div', { class: 'np-exp-title' }));
card.appendChild(npEl('div', { class: 'np-exp-sub' }));
const seek = npEl('input', { class: 'np-seek np-exp-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek') });
bindSeek(seek);
card.appendChild(seek);
const times = npEl('div', { class: 'np-exp-times' }, [
npEl('span', { class: 'np-time-cur' }, [document.createTextNode('0:00')]),
npEl('span', { class: 'np-time-dur' }, [document.createTextNode('0:00')]),
]);
card.appendChild(times);
const controls = npEl('div', { class: 'np-exp-controls' });
controls.appendChild(ctrlButton('prev', 'skip-back', t('player.previous'), () => goSibling(-1)));
const play = ctrlButton('play', 'play', t('player.play'), togglePlay);
play.classList.add('np-exp-play');
controls.appendChild(play);
controls.appendChild(ctrlButton('next', 'skip-forward', t('player.next'), () => goSibling(1)));
card.appendChild(controls);
const extra = npEl('div', { class: 'np-exp-extra' });
const vol = npEl('input', { class: 'np-volume-range', type: 'range', min: '0', max: '1', step: '0.05', value: '1', 'aria-label': t('player.volume') });
vol.addEventListener('input', () => { if (S.media) { S.media.volume = Number(vol.value); S.media.muted = false; } });
const rate = npEl('select', { class: 'np-rate', 'aria-label': t('player.speed') });
['0.5', '0.75', '1', '1.25', '1.5', '2'].forEach((v) => rate.appendChild(npEl('option', { value: v }, [document.createTextNode(v + '×')])));
rate.addEventListener('change', () => { if (S.media) S.media.playbackRate = Number(rate.value); });
extra.appendChild(npEl('span', { class: 'np-vol-icon' }, [npIcon('volume-2', 16)]));
extra.appendChild(vol);
extra.appendChild(rate);
extra.appendChild(ctrlButton('open', 'external-link', t('viewer.image_open_original'), () => window.open(S.streamUrl, '_blank')));
extra.appendChild(ctrlButton('download', 'download', t('viewer.download'), () => window.open(downloadUrl(S.vault, S.path), '_blank')));
extra.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
card.appendChild(extra);
root.appendChild(backdrop);
root.appendChild(card);
return root;
}
function openExpanded() {
ensureHost();
if (!S.expandedEl) {
S.expandedEl = buildExpanded();
S.host.appendChild(S.expandedEl);
}
S.expandedEl.hidden = false;
S.expanded = true;
updateExpanded();
safeCreateIcons();
}
function closeExpanded() {
if (S.expandedEl) S.expandedEl.hidden = true;
S.expanded = false;
}
function updateExpanded() {
const root = S.expandedEl;
if (!root || root.hidden) return;
const title = root.querySelector('.np-exp-title');
if (title) title.textContent = S.title;
const sub = root.querySelector('.np-exp-sub');
if (sub) sub.textContent = S.vault || '';
const play = root.querySelector('.np-exp-play');
setIcon(play, S.media && !S.media.paused ? 'pause' : 'play');
const multi = S.siblings.length > 1;
const prev = root.querySelector('[data-np="prev"]');
const next = root.querySelector('[data-np="next"]');
if (prev) prev.disabled = !multi;
if (next) next.disabled = !multi;
}
// ── Picture-in-Picture ────────────────────────────────────────────────────────
function supportsPip() {
return !!(document.pictureInPictureEnabled) && !!S.media && S.media.tagName === 'VIDEO';
}
function togglePip() {
if (!supportsPip()) return;
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => { /* ignore */ });
} else {
S.media.requestPictureInPicture().catch(() => { /* ignore */ });
}
}
function updatePipButton(active) {
const btn = S.dock && S.dock.querySelector('[data-np="pip"]');
if (btn) btn.classList.toggle('active', !!active);
}
// ── Media Session API (OS / lock screen / hardware keys) ──────────────────────
function updateSessionMetadata() {
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: S.title || '',
artist: S.vault || '',
album: 'ObsiGate',
artwork: [
{ src: '/static/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/static/icon-512.png', sizes: '512x512', type: 'image/png' },
],
});
navigator.mediaSession.setActionHandler('play', () => togglePlay());
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
navigator.mediaSession.setActionHandler('stop', () => stop());
navigator.mediaSession.setActionHandler('seekbackward', (d) => seekBy(-(d?.seekOffset || 10)));
navigator.mediaSession.setActionHandler('seekforward', (d) => seekBy(d?.seekOffset || 10));
navigator.mediaSession.setActionHandler('seekto', (d) => { if (S.media && d && typeof d.seekTime === 'number') S.media.currentTime = d.seekTime; });
navigator.mediaSession.setActionHandler('previoustrack', S.siblings.length > 1 ? () => goSibling(-1) : null);
navigator.mediaSession.setActionHandler('nexttrack', S.siblings.length > 1 ? () => goSibling(1) : null);
} catch (_) { /* unsupported action */ }
}
function syncSessionPlayback() {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.playbackState = S.media && !S.media.paused ? 'playing' : 'paused';
if (S.media && 'setPositionState' in navigator.mediaSession && Number.isFinite(S.media.duration) && S.media.duration > 0) {
navigator.mediaSession.setPositionState({
duration: S.media.duration,
playbackRate: S.media.playbackRate || 1,
position: Math.min(S.media.currentTime || 0, S.media.duration),
});
}
} catch (_) { /* ignore */ }
}
function seekBy(delta) {
if (!S.media) return;
const d = S.media.duration;
const target = (S.media.currentTime || 0) + delta;
S.media.currentTime = Number.isFinite(d) ? Math.max(0, Math.min(d, target)) : Math.max(0, target);
}
function clearSession() {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.metadata = null;
navigator.mediaSession.playbackState = 'none';
} catch (_) { /* ignore */ }
}
// ── Persistence (resume across reloads) ───────────────────────────────────────
function persist() {
if (!S.active || !S.media) return;
try {
localStorage.setItem(LS_KEY, JSON.stringify({
vault: S.vault, path: S.path, title: S.title, kind: S.kind,
streamUrl: S.streamUrl, mime: S.mime, sizeBytes: S.sizeBytes,
time: S.media.currentTime || 0, paused: !!S.media.paused,
}));
} catch (_) { /* quota / private mode */ }
}
function schedulePersist() {
if (S._saveTimer) return;
S._saveTimer = setTimeout(() => { S._saveTimer = null; persist(); }, 2000);
}
function clearPersisted() {
try { localStorage.removeItem(LS_KEY); } catch (_) { /* ignore */ }
}
function restore() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (_) { saved = null; }
if (!saved || !saved.path) return;
S.vault = saved.vault; S.path = saved.path; S.title = saved.title || saved.path.split('/').pop();
S.kind = saved.kind || (isVideoPath(saved.path) ? 'video' : 'audio');
S.streamUrl = saved.streamUrl || buildMediaUrl(saved.vault, saved.path);
S.mime = saved.mime || ''; S.sizeBytes = saved.sizeBytes || 0;
const media = ensureMedia(S.kind);
media.dataset.npKey = `${S.vault}::${S.path}`;
media.src = S.streamUrl;
const time = Number(saved.time) || 0;
if (time > 1) {
media.addEventListener('loadedmetadata', function once() {
try { media.currentTime = time; } catch (_) { /* ignore */ }
media.removeEventListener('loadedmetadata', once);
});
}
S.active = true;
loadSiblings();
updateSessionMetadata();
showDock();
notify(t('player.resume'), 'info');
}
// ── Video dock drag & resize ──────────────────────────────────────────────────
const VIDEO_MIN = { w: 240, h: 150 };
function applyVideoGeometry() {
if (!S.dock || S.dockKind !== 'video') return;
const pos = readPosition();
const mobile = isMobile();
if (mobile) {
S.dock.style.removeProperty('left');
S.dock.style.removeProperty('top');
S.dock.style.removeProperty('width');
S.dock.style.removeProperty('height');
return;
}
const w = pos && pos.w ? pos.w : 360;
const h = pos && pos.h ? pos.h : 220;
S.dock.style.width = w + 'px';
S.dock.style.height = h + 'px';
const vw = window.innerWidth, vh = window.innerHeight;
const margin = 16;
const corner = (pos && pos.corner) || 'br';
let left, top;
if (corner.includes('l')) left = margin; else left = vw - w - margin;
if (corner.includes('t')) top = margin + 56; else top = vh - h - 24;
S.dock.style.left = Math.max(margin, left) + 'px';
S.dock.style.top = Math.max(margin, top) + 'px';
}
function nearestCorner(left, top, w, h) {
const midX = left + w / 2, midY = top + h / 2;
const vw = window.innerWidth, vh = window.innerHeight;
return `${midY < vh / 2 ? 't' : 'b'}${midX < vw / 2 ? 'l' : 'r'}`;
}
function bindVideoDrag(dock, grip) {
if (!grip) return;
grip.style.cursor = 'grab';
grip.addEventListener('pointerdown', (e) => {
if (isMobile()) return;
e.preventDefault();
const rect = dock.getBoundingClientRect();
S._drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, w: rect.width, h: rect.height };
grip.setPointerCapture?.(e.pointerId);
grip.style.cursor = 'grabbing';
document.addEventListener('pointermove', onDragMove);
document.addEventListener('pointerup', onDragEnd);
});
}
function onDragMove(e) {
if (!S._drag || !S.dock) return;
const margin = 8;
const w = S.dock.offsetWidth, h = S.dock.offsetHeight;
let left = e.clientX - S._drag.dx;
let top = e.clientY - S._drag.dy;
left = Math.max(margin, Math.min(window.innerWidth - w - margin, left));
top = Math.max(margin, Math.min(window.innerHeight - h - margin, top));
S.dock.style.left = left + 'px';
S.dock.style.top = top + 'px';
}
function onDragEnd(e) {
document.removeEventListener('pointermove', onDragMove);
document.removeEventListener('pointerup', onDragEnd);
if (!S.dock) return;
const r = S.dock.getBoundingClientRect();
const corner = nearestCorner(r.left, r.top, r.width, r.height);
writePosition({ corner, w: Math.round(r.width), h: Math.round(r.height) });
applyVideoGeometry();
void e;
S._drag = null;
}
function bindVideoResize(dock, handle) {
if (!handle) return;
handle.addEventListener('pointerdown', (e) => {
if (isMobile()) return;
e.preventDefault();
e.stopPropagation();
const rect = dock.getBoundingClientRect();
S._drag = { resize: true, startX: e.clientX, startY: e.clientY, w: rect.width, h: rect.height };
handle.setPointerCapture?.(e.pointerId);
document.addEventListener('pointermove', onResizeMove);
document.addEventListener('pointerup', onResizeEnd);
});
}
function onResizeMove(e) {
if (!S._drag || !S._drag.resize || !S.dock) return;
const w = Math.max(VIDEO_MIN.w, S._drag.w + (e.clientX - S._drag.startX));
const h = Math.max(VIDEO_MIN.h, S._drag.h + (e.clientY - S._drag.startY));
S.dock.style.width = w + 'px';
S.dock.style.height = h + 'px';
}
function onResizeEnd() {
document.removeEventListener('pointermove', onResizeMove);
document.removeEventListener('pointerup', onResizeEnd);
if (S.dock) {
const r = S.dock.getBoundingClientRect();
const pos = readPosition() || {};
writePosition({ ...pos, w: Math.round(r.width), h: Math.round(r.height) });
applyVideoGeometry();
}
S._drag = null;
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onKeydown(e) {
if (!S.active) return;
const host = S.host;
if (!host || (!host.contains(document.activeElement) && !S.expanded)) return;
if (e.key === 'Escape') {
if (S.expanded) { closeExpanded(); }
e.preventDefault();
} else if (e.key === ' ' || e.key === 'k') {
e.preventDefault();
togglePlay();
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
export function initNowPlaying() {
window.NowPlaying = NowPlaying;
ensureHost();
if (!S._keyBound) {
document.addEventListener('keydown', onKeydown);
window.addEventListener('resize', () => applyVideoGeometry());
S._keyBound = true;
}
restore();
}
export const NowPlaying = {
isActive: () => S.active,
matches: (vault, path) => S.active && S.vault === vault && S.path === path,
current: () => (S.active ? { vault: S.vault, path: S.path, kind: S.kind, paused: S.media ? S.media.paused : true } : null),
attachInline,
handleRender,
showDock,
focus: focusNowPlaying,
stop: stopNowPlaying,
notifyTabClosed,
};
export default initNowPlaying;