Pithos 0.3.9: player volume, share links made at once

The music player had no volume control; it now has a slider beside a
mute button (a pop-up slider on narrow screens), remembered between
visits.

The share dialog asked for a "public address of s3d" before making a
link, which nobody could fill in usefully. The link is now made as the
dialog opens, with a plain note that it opens only on this computer
while s3d listens on 127.0.0.1; your own public address moved under
"Use my own public address". The Access dialog says the same about
what public means while s3d is local, and that access can be changed
at any time. Dialogs now draw above the player.
This commit is contained in:
Local Dev 2026-10-04 03:18:24 +02:00
parent 606408035c
commit 9a9d4d4a3f
4 changed files with 76 additions and 15 deletions

View file

@ -1,6 +1,6 @@
{
"name": "pithos",
"version": "0.3.8",
"version": "0.3.9",
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
"license": "MPL-2.0",
"type": "module",

View file

@ -1,7 +1,7 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.3.8",
"version": "0.3.9",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",

View file

@ -184,7 +184,7 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
.steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
/* modal + toasts */
.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; }
.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 25; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; }
.modal h2 { margin-bottom: 14px; }
.modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
@ -399,3 +399,20 @@ body.has-foot.has-player .toasts { bottom: 112px; }
.narrow-only { display: inline-flex; align-items: center; }
table.files td.actions { padding-left: 2px; }
}
/* Player volume */
.pl-volume { position: relative; display: flex; align-items: center; gap: 4px; flex: none; }
.pl-volpop { display: flex; align-items: center; }
.pl-volpop > .pl-btn { display: none; }
.pl-vol { width: 90px; accent-color: var(--accent); margin: 0; }
.pl-volbtn { font-size: 15px; }
@media (max-width: 760px) {
.pl-volpop { display: none; position: absolute; bottom: calc(100% + 10px); right: -6px; padding: 8px 10px; gap: 6px;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgb(0 0 0 / .2); }
.pl-volume.open .pl-volpop { display: flex; }
.pl-volpop > .pl-btn { display: grid; }
.pl-vol { width: 120px; }
}
/* "More options" disclosure in dialogs */
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
details.more-opts > summary:hover { color: var(--text); }

View file

@ -1291,7 +1291,8 @@ async function accessDialog(user, bucket) {
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.'),
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key. You can change this at any time.'),
localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'Right now s3d listens only on this computer, so "public" means public to programs on this computer. Other people reach public files once s3d has a public address.')),
h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')),
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
if (!v || v === current) return;
@ -1526,26 +1527,40 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); }
}
// Share: the link is made as soon as the dialog opens and remade when the
// validity or address changes. s3d listens only on this computer, so
// without an address of your own the link opens only here; it says so.
async function share(key) {
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l)));
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
const out = h('div');
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' });
const out = h('div', {}, h('p', { class: 'muted small' }, 'Making the link…'));
const note = h('div');
let link = '';
const gen = async () => {
const q = new URLSearchParams({ key, expires: expires.value });
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); }
const own = endpoint.value.trim();
try { localStorage.setItem('pithos.publicEndpoint', own); } catch {}
if (own) q.set('endpoint', own);
try {
const { url } = await api('GET', `${base}/presign?${q}`);
put(out, secretRow('Link', url));
} catch (e) { fail(e); }
return false;
({ url: link } = await api('GET', `${base}/presign?${q}`));
put(out, secretRow('Link', link));
} catch (e) { put(out, h('p', { class: 'error small' }, e.message)); }
put(note, !own && localOnly() && h('div', { class: 'banner info small', style: 'margin:0' },
h('span', {}, 'This link opens only on this computer: s3d listens on ', h('code', {}, state.status?.config.apiAddress || '127.0.0.1'), ', which other devices cannot reach. To share with other people, s3d needs a public address (for example your own server in front of it).')));
};
expires.addEventListener('change', gen);
endpoint.addEventListener('change', gen);
gen();
await modal('Share link', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')),
out),
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
out,
note,
h('details', { class: 'more-opts', open: !!savedEndpoint },
h('summary', {}, 'Use my own public address'),
h('label', { class: 'field', style: 'margin-top:8px' }, endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.')))),
[{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]);
}
load();
@ -1577,9 +1592,33 @@ function player() {
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() }, '✕');
// Volume: a slider beside a mute button; on narrow screens the button
// opens the slider in a small pop-up above it. Remembered between visits.
const saved = (() => { try { return JSON.parse(localStorage.getItem('pithos.volume') || 'null'); } catch { return null; } })();
audio.volume = saved && isFinite(saved.v) ? Math.min(1, Math.max(0, saved.v)) : 1;
audio.muted = !!saved?.m;
const volIcon = () => (audio.muted || audio.volume === 0 ? '🔇' : audio.volume < 0.5 ? '🔉' : '🔊');
const vol = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(audio.volume), class: 'pl-vol', 'aria-label': 'Volume',
oninput: () => { audio.volume = +vol.value; if (audio.volume > 0) audio.muted = false; } });
const volBtn = h('button', { type: 'button', class: 'pl-btn pl-volbtn', title: 'Mute', 'aria-label': 'Mute', onclick: () => {
if (matchMedia('(max-width: 760px)').matches) { volBox.classList.toggle('open'); return; }
audio.muted = !audio.muted;
} }, volIcon());
const muteInPop = h('button', { type: 'button', class: 'pl-btn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { audio.muted = !audio.muted; } }, '🔇');
const volBox = h('div', { class: 'pl-volume' }, volBtn, h('div', { class: 'pl-volpop' }, muteInPop, vol));
audio.addEventListener('volumechange', () => {
volBtn.textContent = volIcon();
const m = audio.muted ? 'Unmute' : 'Mute';
volBtn.title = muteInPop.title = m; muteInPop.setAttribute('aria-label', m);
muteInPop.textContent = audio.muted ? '🔈' : '🔇';
vol.value = String(audio.muted ? 0 : audio.volume);
try { localStorage.setItem('pithos.volume', JSON.stringify({ v: audio.volume, m: audio.muted })); } catch {}
});
document.addEventListener('mousedown', (e) => { if (!volBox.contains(e.target)) volBox.classList.remove('open'); });
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)),
volBox,
closeBtn);
document.body.append(el);
@ -1801,6 +1840,11 @@ $('#unlock-btn').addEventListener('click', async () => {
// hours; a newer release replaces the version with "↑ vX". One click
// downloads and verifies it, the next restarts Theseus to apply it.
const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json';
// s3d's S3 port is reachable only from this computer (the default).
function localOnly() {
const a = state.status?.config?.apiAddress || '';
return /^(127\.|localhost|\[::1\])/.test(a);
}
function cmpSemver(a, b) {
const pa = String(a || '0').split('.').map((n) => Number(n) || 0);
const pb = String(b || '0').split('.').map((n) => Number(n) || 0);