Files
ObsiGate/frontend/js/now-playing.js
T
bruno 80852374a8
CI / lint (push) Successful in 2m0s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m7s
fix: positionnement lecteur media (mobile/desktop) et drag libre de la mini-video #110
2026-09-23 11:42:23 -04:00

1001 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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', title: t('player.expand'), role: 'button', tabindex: '0' });
info.appendChild(npEl('span', { class: 'np-dock-title' }));
info.appendChild(npEl('span', { class: 'np-dock-sub' }));
info.addEventListener('click', (e) => { stopEvent(e); toggleExpanded(); });
info.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpanded(); }
});
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);
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 (free positioning anywhere in the viewport) ──────
const VIDEO_MIN = { w: 240, h: 150 };
const VIDEO_DEFAULT = { w: 360, h: 220 };
function clampToViewport(left, top, w, h) {
const margin = 8;
const maxLeft = Math.max(margin, window.innerWidth - w - margin);
const maxTop = Math.max(margin, window.innerHeight - h - margin);
return {
left: Math.min(Math.max(margin, left), maxLeft),
top: Math.min(Math.max(margin, top), maxTop),
};
}
function applyVideoGeometry() {
if (!S.dock || S.dockKind !== 'video') return;
if (isMobile()) {
S.dock.style.removeProperty('left');
S.dock.style.removeProperty('top');
S.dock.style.removeProperty('width');
S.dock.style.removeProperty('height');
return;
}
const pos = readPosition() || {};
const w = pos.w || VIDEO_DEFAULT.w;
const h = pos.h || VIDEO_DEFAULT.h;
S.dock.style.width = w + 'px';
S.dock.style.height = h + 'px';
const margin = 16;
const left = typeof pos.left === 'number' ? pos.left : window.innerWidth - w - margin;
const top = typeof pos.top === 'number' ? pos.top : window.innerHeight - h - 24;
const c = clampToViewport(left, top, w, h);
S.dock.style.left = c.left + 'px';
S.dock.style.top = c.top + 'px';
}
// Drag from anywhere on the mini-window (buttons, sliders and the resize handle
// excluded). A small movement threshold keeps clicks on the native video
// controls working; no pointer capture is used so those controls keep working.
function bindVideoDrag(dock) {
dock.addEventListener('pointerdown', (e) => {
if (isMobile() || e.button !== 0) return;
if (e.target.closest('button, input, select, textarea, a, .np-video-resize')) return;
const rect = dock.getBoundingClientRect();
S._drag = {
dx: e.clientX - rect.left,
dy: e.clientY - rect.top,
startX: e.clientX,
startY: e.clientY,
moved: false,
};
document.addEventListener('pointermove', onDragMove);
document.addEventListener('pointerup', onDragEnd);
});
}
function onDragMove(e) {
if (!S._drag || S._drag.resize || !S.dock) return;
if (!S._drag.moved) {
if (Math.abs(e.clientX - S._drag.startX) < 4 && Math.abs(e.clientY - S._drag.startY) < 4) return;
S._drag.moved = true;
S.dock.classList.add('np-dragging');
}
const w = S.dock.offsetWidth, h = S.dock.offsetHeight;
const c = clampToViewport(e.clientX - S._drag.dx, e.clientY - S._drag.dy, w, h);
S.dock.style.left = c.left + 'px';
S.dock.style.top = c.top + 'px';
}
function onDragEnd() {
document.removeEventListener('pointermove', onDragMove);
document.removeEventListener('pointerup', onDragEnd);
if (!S.dock) { S._drag = null; return; }
S.dock.classList.remove('np-dragging');
if (S._drag && S._drag.moved) {
const r = S.dock.getBoundingClientRect();
const pos = readPosition() || {};
writePosition({
...pos,
left: Math.round(r.left),
top: Math.round(r.top),
w: Math.round(r.width),
h: Math.round(r.height),
});
}
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 c = clampToViewport(r.left, r.top, r.width, r.height);
writePosition({ left: Math.round(c.left), top: Math.round(c.top), 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;