981 lines
38 KiB
JavaScript
981 lines
38 KiB
JavaScript
/* 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;
|