feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title data-i18n="header.logo">ObsiGate</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -2028,6 +2028,23 @@
|
||||
"mfa.webauthn_btn": "Verify with my key",
|
||||
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
|
||||
"mfa.webauthn_no_key": "No security key registered for this account.",
|
||||
"player.now_playing": "Now playing",
|
||||
"player.play": "Play",
|
||||
"player.pause": "Pause",
|
||||
"player.previous": "Previous",
|
||||
"player.next": "Next",
|
||||
"player.seek": "Seek",
|
||||
"player.volume": "Volume",
|
||||
"player.speed": "Playback speed",
|
||||
"player.expand": "Expand player",
|
||||
"player.minimize": "Minimize player",
|
||||
"player.close": "Stop and close player",
|
||||
"player.continues": "Playback continues — use the player to stop it.",
|
||||
"player.reopen_tab": "Return to media",
|
||||
"player.pip": "Picture in picture",
|
||||
"player.move": "Move",
|
||||
"player.resize": "Resize",
|
||||
"player.resume": "Resuming last playback",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
|
||||
"plugins.install": "Install Plugin",
|
||||
@@ -2129,7 +2146,7 @@
|
||||
"guide105.lib_h3_conflicts": "Sync conflicts",
|
||||
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
|
||||
"guide105.lib_h3_attach": "Attachments & media",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
|
||||
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
|
||||
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
|
||||
|
||||
@@ -2028,6 +2028,23 @@
|
||||
"mfa.webauthn_btn": "Valider avec ma clé",
|
||||
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
|
||||
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
|
||||
"player.now_playing": "Lecture en cours",
|
||||
"player.play": "Lecture",
|
||||
"player.pause": "Pause",
|
||||
"player.previous": "Précédent",
|
||||
"player.next": "Suivant",
|
||||
"player.seek": "Position de lecture",
|
||||
"player.volume": "Volume",
|
||||
"player.speed": "Vitesse de lecture",
|
||||
"player.expand": "Agrandir le lecteur",
|
||||
"player.minimize": "Réduire le lecteur",
|
||||
"player.close": "Arrêter et fermer le lecteur",
|
||||
"player.continues": "Lecture en cours — utilisez le lecteur pour l'arrêter.",
|
||||
"player.reopen_tab": "Revenir au média",
|
||||
"player.pip": "Image dans l'image",
|
||||
"player.move": "Déplacer",
|
||||
"player.resize": "Redimensionner",
|
||||
"player.resume": "Reprise de la dernière lecture",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
|
||||
"plugins.install": "Installer le plugin",
|
||||
@@ -2129,7 +2146,7 @@
|
||||
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
|
||||
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
|
||||
"guide105.lib_h3_attach": "Fichiers joints & médias",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
|
||||
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
|
||||
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
|
||||
|
||||
+423
-21
@@ -9569,14 +9569,14 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-info {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pdf-iframe {
|
||||
flex: 1;
|
||||
@@ -9601,7 +9601,7 @@ body.desktop-mode .editor-container {
|
||||
.pdf-toc h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pdf-toc ul {
|
||||
list-style: none;
|
||||
@@ -9620,7 +9620,7 @@ body.desktop-mode .editor-container {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.toc-page {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -9631,7 +9631,7 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
padding: 20px;
|
||||
}
|
||||
.image-viewer-body img {
|
||||
@@ -9651,7 +9651,7 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -9669,7 +9669,7 @@ body.desktop-mode .editor-container {
|
||||
.image-zoom-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -9698,7 +9698,7 @@ body.desktop-mode .editor-container {
|
||||
will-change: transform;
|
||||
}
|
||||
.image-error {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-nav-strip {
|
||||
@@ -9706,7 +9706,7 @@ body.desktop-mode .editor-container {
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -9732,10 +9732,10 @@ body.desktop-mode .editor-container {
|
||||
}
|
||||
.image-meta-panel {
|
||||
padding: 10px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-meta-panel[hidden] {
|
||||
@@ -9748,7 +9748,7 @@ body.desktop-mode .editor-container {
|
||||
margin: 0;
|
||||
}
|
||||
.image-meta-panel dt {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.image-meta-panel dd {
|
||||
margin: 0;
|
||||
@@ -9778,7 +9778,7 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -9796,13 +9796,13 @@ body.desktop-mode .editor-container {
|
||||
.media-duration-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
.media-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.audio-stage {
|
||||
flex: 1;
|
||||
@@ -9822,7 +9822,7 @@ body.desktop-mode .editor-container {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -9849,6 +9849,408 @@ body.desktop-mode .editor-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Now Playing — persistent media dock (roadmap #110) ── */
|
||||
.np-inline-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.np-inline-slot .np-media--audio {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.np-media--video {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
.np-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
pointer-events: none;
|
||||
}
|
||||
.np-dock {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.np-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.np-ctrl:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-ctrl:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.np-ctrl:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.np-dock--audio {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.np-dock--audio .np-media {
|
||||
display: none;
|
||||
}
|
||||
.np-dock-art {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-dock-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.np-dock-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.np-dock-sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.np-dock-controls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.np-dock-progress {
|
||||
flex: 1 1 180px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.np-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
.np-seek {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-seek::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-seek::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-dock-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.np-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.np-volume-range {
|
||||
width: 70px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-volume-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-volume-range::-moz-range-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Video dock — floating, draggable, resizable mini-window */
|
||||
.np-dock--video {
|
||||
position: fixed;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.np-dock-video-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.np-dock-video-slot {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.np-dock-video-slot .np-media--video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
max-height: none;
|
||||
}
|
||||
.np-dock-video-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
.np-dock-video-bar .np-ctrl,
|
||||
.np-dock-video-bar .np-time {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.np-dock-video-bar .np-seek {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.np-dock-video-bar .np-ctrl:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
}
|
||||
.np-video-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.np-video-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.np-video-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.np-video-resize::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-right: 2px solid rgba(255, 255, 255, 0.55);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.np-dock--error .np-dock-error-msg {
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Expanded audio panel (bottom sheet on mobile) */
|
||||
.np-expanded {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 960;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.np-expanded[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.np-expanded-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.np-exp-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 96px;
|
||||
transform: translateX(-50%);
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.np-exp-art {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-exp-title {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.np-exp-sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.np-exp-seek {
|
||||
width: 100%;
|
||||
}
|
||||
.np-exp-times {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.np-exp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.np-exp-controls .np-ctrl {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.np-exp-play {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.np-exp-play:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.np-exp-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.np-rate {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.np-dock--audio {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px);
|
||||
transform: none;
|
||||
width: auto;
|
||||
}
|
||||
.np-dock-progress {
|
||||
flex-basis: 90px;
|
||||
}
|
||||
.np-volume {
|
||||
display: none;
|
||||
}
|
||||
.np-dock--video {
|
||||
left: 8px !important;
|
||||
right: 8px;
|
||||
top: auto !important;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px) !important;
|
||||
width: auto !important;
|
||||
height: 200px !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.np-video-resize {
|
||||
display: none;
|
||||
}
|
||||
.np-exp-card {
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
|
||||
}
|
||||
body.np-active .content-area {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 76px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.np-ctrl,
|
||||
.np-dock,
|
||||
.np-exp-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Text / CSV Viewer ── */
|
||||
.text-viewer-container {
|
||||
display: flex;
|
||||
@@ -9860,14 +10262,14 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-info {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-viewer-body {
|
||||
flex: 1;
|
||||
@@ -9897,8 +10299,8 @@ body.desktop-mode .editor-container {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.csv-table th {
|
||||
background: var(--surface1);
|
||||
color: var(--text-dim);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
@@ -9911,7 +10313,7 @@ body.desktop-mode .editor-container {
|
||||
border-bottom: 1px solid var(--border-light, var(--border));
|
||||
}
|
||||
.csv-table tr:hover td {
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── JSON Viewer ── */
|
||||
|
||||
Reference in New Issue
Block a user