From d31e789c557aa0da2c11eaee2e97d496ee10e630 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:23:06 +0200 Subject: [PATCH] Pithos 0.3.7: music keeps playing while you browse Opening an MP3 used to show it in a dialog, so the music stopped as soon as you went on looking through your drive. Audio now plays in a player docked at the bottom of Pithos that stays through folder, drive and page changes and plays on through the folder it started from, with previous, next, seek and the keyboard's media keys. An expired view link is renewed mid-track instead of stopping playback. --- ui/app.css | 27 +++++++++++++ ui/app.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/ui/app.css b/ui/app.css index fdb67e6..75fd5b9 100644 --- a/ui/app.css +++ b/ui/app.css @@ -324,3 +324,30 @@ tr.clickable:focus-visible { outline: none; } .menu button { appearance: none; border: none; background: none; color: var(--text); font: inherit; text-align: left; padding: 7px 10px; border-radius: 6px; cursor: pointer; } .menu button:hover, .menu button:focus-visible { background: var(--surface-2); outline: none; } .menu button.danger { color: var(--danger); } + +/* Music player, docked at the bottom; stays while you browse */ +.player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 15; display: flex; align-items: center; gap: 12px; + padding: 8px 14px; background: var(--surface); border-top: 1px solid var(--border); box-shadow: 0 -4px 16px rgb(0 0 0 / .12); } +@media (min-width: 761px) { .player { left: 220px; } } +body.has-player main { padding-bottom: 96px; } +body.has-player .toasts { bottom: 84px; } +.pl-controls { display: flex; align-items: center; gap: 2px; flex: none; } +.pl-btn { appearance: none; border: none; background: none; color: var(--text); font: inherit; font-size: 16px; width: 32px; height: 32px; + border-radius: 50%; cursor: pointer; display: grid; place-items: center; } +.pl-btn:hover:not(:disabled) { background: var(--surface-2); } +.pl-btn:disabled { opacity: .35; cursor: default; } +.pl-main { width: 38px; height: 38px; background: var(--accent); color: var(--accent-ink); font-size: 15px; } +.pl-main:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); } +.pl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.pl-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.pl-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.pl-where { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 0; min-width: 0; } +.pl-seek { display: flex; align-items: center; gap: 8px; } +.pl-seek input { flex: 1; min-width: 0; accent-color: var(--accent); margin: 0; } +.pl-time { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 2.6em; text-align: center; } +@media (max-width: 420px) { + .player { gap: 6px; padding: 6px 8px; } + .pl-where { display: none; } + .pl-btn { width: 28px; height: 28px; font-size: 14px; } + .pl-main { width: 34px; height: 34px; } +} diff --git a/ui/app.js b/ui/app.js index 3d427bb..8a03d86 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1322,7 +1322,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const fileName = (o) => o.key.slice(prefix.length); const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]); - const fileMenu = (o) => itemMenu([['Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); @@ -1452,6 +1452,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { async function openViewer(o) { const name = o.key.slice(prefix.length); const kind = fileKind(o.key); + if (kind === 'audio') return playFolder(o); const path = `${base}/object?key=${encodeURIComponent(o.key)}`; const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`); let content; @@ -1468,6 +1469,17 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { [{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]); } + // Music plays in the docked player, on through this folder's other tracks. + function playFolder(o) { + const where = `${bucket}/${prefix}`.replace(/\/$/, ''); + const tracks = pages.flatMap((p) => p.objects).filter((x) => fileKind(x.key) === 'audio').map((x) => { + const path = `${base}/object?key=${encodeURIComponent(x.key)}`; + return { key: x.key, name: x.key.slice(prefix.length), where, + src: async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`) }; + }); + player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key))); + } + async function deleteObject(key) { if (!(await confirmModal('Delete object?', key))) return; try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } @@ -1503,6 +1515,102 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { load(); } +// ---------------------------------------------------------------- music player + +// A player docked at the bottom of Pithos: it lives outside the page, so it +// keeps playing while you browse other folders, drives or pages, and plays +// on through the folder it was started from. +let musicPlayer = null; +function player() { + if (musicPlayer) return musicPlayer; + const audio = new Audio(); + audio.preload = 'metadata'; + let queue = []; + let at = -1; + let seeking = false; + let retried = false; + const mmss = (t) => (isFinite(t) ? `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` : '0:00'); + const title = h('div', { class: 'pl-title' }); + const where = h('div', { class: 'pl-where small muted' }); + const cur = h('span', { class: 'pl-time' }, '0:00'); + const dur = h('span', { class: 'pl-time' }, '0:00'); + const seek = h('input', { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': 'Position', + oninput: () => { seeking = true; cur.textContent = mmss(+seek.value); }, + onchange: () => { audio.currentTime = +seek.value; seeking = false; } }); + const playBtn = h('button', { type: 'button', class: 'pl-btn pl-main', 'aria-label': 'Play', onclick: () => (audio.paused ? audio.play().catch(() => {}) : audio.pause()) }, '▶'); + const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮'); + const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭'); + const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕'); + const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true }, + h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn), + h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)), + closeBtn); + document.body.append(el); + + const showPlaying = () => { const p = !audio.paused; playBtn.textContent = p ? '⏸' : '▶'; playBtn.setAttribute('aria-label', p ? 'Pause' : 'Play'); if (navigator.mediaSession) navigator.mediaSession.playbackState = p ? 'playing' : 'paused'; }; + audio.addEventListener('play', showPlaying); + audio.addEventListener('pause', showPlaying); + audio.addEventListener('playing', () => { retried = false; }); + audio.addEventListener('loadedmetadata', () => { seek.max = String(Math.floor(audio.duration) || 0); dur.textContent = mmss(audio.duration); }); + audio.addEventListener('timeupdate', () => { if (!seeking) { seek.value = String(Math.floor(audio.currentTime)); cur.textContent = mmss(audio.currentTime); } }); + audio.addEventListener('ended', () => { if (at < queue.length - 1) next(); }); + // Theseus view links last 10 minutes: when one runs out mid-track, fetch a + // fresh link and carry on from the same spot. + audio.addEventListener('error', () => { + if (!queue[at] || retried) return toast(`Cannot play ${queue[at]?.name || 'this file'}`, 'error'); + retried = true; + load(at, audio.currentTime, true); + }); + + async function load(i, from = 0, play = true) { + at = i; + const t = queue[i]; + title.textContent = t.name; + title.title = t.name; + where.textContent = t.where; + prevBtn.disabled = i <= 0; + nextBtn.disabled = i >= queue.length - 1; + let src; + try { src = await t.src(); } catch (e) { return fail(e); } + if (at !== i) return; // another track was chosen meanwhile + audio.src = src; + if (from) audio.addEventListener('loadedmetadata', () => { audio.currentTime = from; }, { once: true }); + if (play) audio.play().catch(() => {}); + if (navigator.mediaSession && window.MediaMetadata) navigator.mediaSession.metadata = new MediaMetadata({ title: t.name.replace(/\.[^.]+$/, ''), album: t.where, artist: 'Pithos' }); + } + const next = () => { if (at < queue.length - 1) load(at + 1); }; + const prev = () => { if (audio.currentTime > 3 || at <= 0) audio.currentTime = 0; else load(at - 1); }; + function close() { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + queue = []; at = -1; + el.hidden = true; + document.body.classList.remove('has-player'); + } + if (navigator.mediaSession) { + const ms = navigator.mediaSession; + try { + ms.setActionHandler('play', () => audio.play().catch(() => {})); + ms.setActionHandler('pause', () => audio.pause()); + ms.setActionHandler('previoustrack', prev); + ms.setActionHandler('nexttrack', next); + ms.setActionHandler('seekto', (d) => { audio.currentTime = d.seekTime; }); + } catch {} + } + musicPlayer = { + // tracks: [{ key, name, where, src: async () => url }] + play(tracks, index) { + queue = tracks; + el.hidden = false; + document.body.classList.add('has-player'); + load(index); + }, + get current() { return queue[at]?.key || null; }, + }; + return musicPlayer; +} + // ---------------------------------------------------------------- settings function settings(main) {