Theseus: bundle Pithos 0.3.7 with the music player

This commit is contained in:
Local Dev 2026-10-04 02:23:07 +02:00
parent 61144e821e
commit 449a967e21
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 { 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:hover, .menu button:focus-visible { background: var(--surface-2); outline: none; }
.menu button.danger { color: var(--danger); } .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; }
}

View file

@ -1322,7 +1322,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
const fileName = (o) => o.key.slice(prefix.length); const fileName = (o) => o.key.slice(prefix.length);
const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]); 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 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(); const stop = (e) => e.stopPropagation();
@ -1452,6 +1452,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
async function openViewer(o) { async function openViewer(o) {
const name = o.key.slice(prefix.length); const name = o.key.slice(prefix.length);
const kind = fileKind(o.key); const kind = fileKind(o.key);
if (kind === 'audio') return playFolder(o);
const path = `${base}/object?key=${encodeURIComponent(o.key)}`; const path = `${base}/object?key=${encodeURIComponent(o.key)}`;
const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`); const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`);
let content; 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 }]); [{ 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) { async function deleteObject(key) {
if (!(await confirmModal('Delete object?', key))) return; if (!(await confirmModal('Delete object?', key))) return;
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } 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(); 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 // ---------------------------------------------------------------- settings
function settings(main) { function settings(main) {