Theseus: bundle Pithos 0.3.7 with the music player
This commit is contained in:
parent
61144e821e
commit
449a967e21
2 changed files with 136 additions and 1 deletions
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue