diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index fdb67e65..75fd5b95 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/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/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 3d427bbd..8a03d863 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/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) {