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.
This commit is contained in:
Local Dev 2026-10-04 02:23:06 +02:00
parent ebd5b8ffe6
commit d31e789c55
2 changed files with 136 additions and 1 deletions

View file

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

110
ui/app.js
View file

@ -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) {