@@ -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 ;