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
+2
View File
@@ -6,6 +6,7 @@ import * as UI from './ui.js';
import * as Utils from './utils.js';
import { initI18n, t } from './i18n.js';
import { initAIFab } from './ai-fab.js';
import { initNowPlaying } from './now-playing.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
window._obsigateShowToast = UI.showToast;
@@ -113,6 +114,7 @@ async function init() {
setupFocusMode();
Utils.safeCreateIcons();
initAIFab();
initNowPlaying();
}
document.addEventListener("DOMContentLoaded", async () => {
+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;
+8
View File
@@ -197,9 +197,13 @@ function createPaneTabManager(paneId) {
close(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
// If this pane has no more tabs and isn't the last pane, close it
@@ -826,6 +830,8 @@ const PaneManager = {
grid.className = 'pane-grid';
this._applyGridTemplate(grid, n);
// #110 — keep playing media alive across a pane-grid rebuild.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
@@ -1216,6 +1222,8 @@ const PaneManager = {
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
// #110 — keep playing media alive across a pane collapse.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
let tabBar = null, content = null;
if (pane0 && pane0.element) {
+7
View File
@@ -2080,11 +2080,16 @@ export const TabManager = {
}
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
this._showDashboard();
@@ -2207,6 +2212,8 @@ export const TabManager = {
_showDashboard() {
const area = document.getElementById("content-area");
// #110 — move any playing media to the persistent dock before wiping.
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
let dashboard = document.getElementById("dashboard-home");
if (!dashboard) {
+17 -129
View File
@@ -14,6 +14,7 @@ import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -782,139 +783,24 @@ export function renderImageViewer(area, data) {
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
let _mediaViewerCleanup = null;
// #110 — Audio/video are handled by the global Now Playing controller
// (frontend/js/now-playing.js): a single shared media element is teleported
// between this inline view and a body-level dock, so playback survives tab,
// pane and page navigation. The inline branches below just hand over the
// surface; the legacy "stop on re-render" cleanup no longer applies.
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
/** 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 HTML5 media players (#109). */
export function buildMediaUrl(vault, path) {
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildDownloadUrl(vault, path) {
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
}
/**
* Fallback shown when a media codec is not playable in the browser (roadmap
* #109-C3) or when the file is too large to stream inline.
*/
export function renderMediaFallback(area, data, message) {
const fileName = (data.path || "").split("/").pop();
const sizeStr = formatBytes(data.size_bytes);
area.innerHTML = "";
const box = el("div", { class: "unsupported-file" }, [
icon("file", 48),
el("div", { class: "filename" }, [document.createTextNode(fileName)]),
el("div", {}, [document.createTextNode(message)]),
sizeStr ? el("div", { style: "font-size:0.85rem;margin-top:4px" }, [document.createTextNode(t("viewer.metadata_size") + " : " + sizeStr)]) : null,
]);
const dlBtn = el("button", { class: "btn-action", type: "button" }, [
icon("download", 14), document.createTextNode(" " + t("viewer.download")),
]);
dlBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
const openBtn = el("button", { class: "btn-action", type: "button" }, [
icon("external-link", 14), document.createTextNode(" " + t("viewer.image_open_original")),
]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
box.appendChild(el("div", { class: "media-fallback-actions" }, [dlBtn, openBtn]));
area.appendChild(box);
safeCreateIcons();
}
function mediaToolbar(data, extraButtons) {
const toolbar = el("div", { class: "media-toolbar" });
toolbar.appendChild(el("span", { class: "media-title", title: data.path }, [
document.createTextNode(data.title || (data.path || "").split("/").pop()),
]));
toolbar.appendChild(el("div", { class: "media-toolbar-spacer" }));
(extraButtons || []).forEach((b) => toolbar.appendChild(b));
const openBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.image_open_original"), "aria-label": t("viewer.image_open_original") }, [icon("external-link", 14)]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
const downloadBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.download"), "aria-label": t("viewer.download") }, [icon("download", 14)]);
downloadBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
toolbar.appendChild(openBtn);
toolbar.appendChild(downloadBtn);
return toolbar;
}
/**
* Render an audio player (roadmap #109-B): full-width native ``<audio>``,
* artwork placeholder, duration read from ``loadedmetadata`` and a toolbar
* with the vault, size, open-original and download actions.
*/
/** Audio viewer (roadmap #109-B, made persistent by #110). */
export function renderAudioViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "audio-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "audio-stage" });
const artwork = el("div", { class: "audio-artwork" }, [icon("audio-lines", 64)]);
const audio = el("audio", { class: "audio-player", controls: "", preload: "metadata", src: streamUrl });
stage.appendChild(artwork);
stage.appendChild(audio);
container.appendChild(stage);
audio.addEventListener("loadedmetadata", () => {
badge.textContent = formatMediaDuration(audio.duration) || "--:--";
});
audio.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { audio.pause(); audio.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
NowPlaying.attachInline(area, data);
}
/**
* Render a video player (roadmap #109-C): centered native ``<video>`` with a
* black letterboxed stage and a toolbar. Playing is stopped when the content
* area is re-rendered (tab switch / navigation).
*/
/** Video viewer (roadmap #109-C, made persistent by #110). */
export function renderVideoViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "video-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "video-stage" });
const video = el("video", { class: "video-player", controls: "", playsinline: "", preload: "metadata", src: streamUrl });
stage.appendChild(video);
container.appendChild(stage);
video.addEventListener("loadedmetadata", () => {
const parts = [formatMediaDuration(video.duration) || "--:--"];
if (video.videoWidth && video.videoHeight) parts.push(`${video.videoWidth} × ${video.videoHeight}`);
badge.textContent = parts.join(" · ");
});
video.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { video.pause(); video.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
NowPlaying.attachInline(area, data);
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
@@ -924,10 +810,10 @@ export function renderFile(data) {
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// #109 — stop any playing audio/video when the file view is replaced
// (tab switch, navigation): no persistent playback in v1.
if (_mediaViewerCleanup) { _mediaViewerCleanup(); _mediaViewerCleanup = null; }
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
@@ -1669,6 +1555,8 @@ export function showWelcome() {
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
NowPlaying.handleRender(area, null);
const home = document.getElementById("dashboard-home");
if (area && !home) {