feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user