Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6fff51bb | ||
|
|
3db4e4aa1e | ||
|
|
4f482a1e1a | ||
|
|
8b295dddef | ||
|
|
7a07ac5e11 | ||
|
|
78fd07bd0d |
No files matched your search
@@ -55,6 +55,9 @@ async function loadSettings() {
|
||||
|
||||
const ICONS = {
|
||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
||||
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
|
||||
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
|
||||
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
|
||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||
|
||||
@@ -38,7 +38,15 @@ async function chLoadSignal() {
|
||||
ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return;
|
||||
try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; }
|
||||
}
|
||||
const chSigRow = c => `<a class="row sgrow" data-sig="1" href="#/signal/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
|
||||
const chSigRow = c => `<a class="row sgrow ${c.id === ch.curSig ? 'on' : ''}" data-sig="1" href="${sgHref(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
|
||||
<span class="grow"><b>${esc(c.name)} <small class="srcbadge">Signal</small></b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`;
|
||||
/** Signal changed (a message came in): redraw the Chat list if it is showing. */
|
||||
function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); }
|
||||
|
||||
/** A Signal conversation shown in Chat's own conversation pane (so choosing one in the combined list never leaves Chat). */
|
||||
async function chShowSignal() {
|
||||
const box = chq('#chm'); if (!box) return;
|
||||
if (!box.querySelector('#sgm')) { box.innerHTML = '<div id="sgm" class="sgin"></div>'; sg.reply = null; sg.tray = []; }
|
||||
sg.root = ch.root; sg.dock = false; sg.inChat = true; sg.cur = ch.curSig;
|
||||
await sgDrawThread(true);
|
||||
}
|
||||
+5
-4
@@ -34,10 +34,10 @@ function chEvent(ev) {
|
||||
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
|
||||
}
|
||||
|
||||
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
|
||||
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
|
||||
async function chMount(root) {
|
||||
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
||||
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
||||
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
||||
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
||||
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
||||
await chRefresh(true);
|
||||
@@ -46,7 +46,7 @@ async function chRefresh(first) {
|
||||
try {
|
||||
const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
|
||||
if (!chq('#chlist')) return; chDrawList();
|
||||
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
||||
if (ch.curSig && typeof chShowSignal === 'function') await chShowSignal(); else if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
||||
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
||||
}
|
||||
async function chSearchMessages() {
|
||||
@@ -189,9 +189,10 @@ async function chPeople() { if (!ch.people) ch.people = await chApi('/people');
|
||||
async function chNewChat() {
|
||||
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
|
||||
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
|
||||
<div class="bar" style="flex-wrap:wrap">${people.length > 1 ? '<button class="btn ghost" id="chg" style="flex:1;justify-content:center">New group…</button>' : ''}${typeof sgNewChat === 'function' && typeof sgInChat === 'function' && sgInChat() ? '<button class="btn ghost" id="chsgn" style="flex:1;justify-content:center">New Signal chat…</button>' : ''}</div>`);
|
||||
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
|
||||
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
|
||||
const sn = el.querySelector('#chsgn'); if (sn) sn.onclick = () => { closeSheet(); sg.inChat = true; sg.root = ch.root; sgNewChat(); };
|
||||
}
|
||||
function chNewGroup(people) {
|
||||
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
|
||||
|
||||
+26
-11
@@ -21,7 +21,7 @@ function lyrParseLrc(text) {
|
||||
}
|
||||
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
||||
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
||||
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
||||
const lyr = { fs: false, open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
||||
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
||||
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
|
||||
@@ -77,41 +77,49 @@ async function lyrLoad(t, force) {
|
||||
return lyrForRadio(t, res);
|
||||
}
|
||||
|
||||
function lyrOpen() {
|
||||
const t = cur(); if (!t || lyr.open) return;
|
||||
lyr.open = true; document.body.classList.add('lyropen');
|
||||
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
|
||||
function lyrOpen(fs) {
|
||||
const t = cur(); if (!t) return;
|
||||
if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; }
|
||||
lyr.fs = fs === true; lyr.open = true; document.body.classList.add('lyropen');
|
||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||
}
|
||||
function lyrClose() {
|
||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||
}
|
||||
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
|
||||
function lyrShell() {
|
||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
||||
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
|
||||
lyr.el.className = lyrCls();
|
||||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
||||
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
||||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></div>
|
||||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
||||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</button></div>`;
|
||||
const $l = q => lyr.el.querySelector(q);
|
||||
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
||||
const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; };
|
||||
$l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {});
|
||||
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
||||
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
||||
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
||||
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
||||
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
||||
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
||||
syncUI();
|
||||
modeUI(); syncUI();
|
||||
}
|
||||
async function lyrReload(force) {
|
||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
||||
@@ -129,14 +137,21 @@ function lyrDraw() {
|
||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
||||
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
||||
}
|
||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
|
||||
}
|
||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; lyrQueue(body); return; }
|
||||
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
||||
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
||||
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
||||
body.scrollTop = 0; lyr.idx = -2;
|
||||
}
|
||||
/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */
|
||||
function lyrQueue(body) {
|
||||
if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return;
|
||||
const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40);
|
||||
body.insertAdjacentHTML('beforeend', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
|
||||
body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
|
||||
}
|
||||
function lyrSearchAgain() {
|
||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
||||
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||||
|
||||
+10
-1
@@ -173,7 +173,7 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
||||
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
||||
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
@@ -182,6 +182,15 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
|
||||
if (mine) {
|
||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||
$('#pmv').onclick = () => tracks.length ? textSheet('Move to NE Hub', 'Name of the Family playlist', 'Move', async n => {
|
||||
try {
|
||||
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key)); if (!meta.length) return toast('No songs here can be moved');
|
||||
const r = await mmApi('/family', mmOpt('POST', { name: n, tracks: meta.slice(0, 500) }));
|
||||
for (let i = 500; i < meta.length; i += 500) await mmApi(`/family/${r.id}/add`, mmOpt('POST', { tracks: meta.slice(i, i + 500) }));
|
||||
toast('Moved ' + meta.length + ' songs to NE Hub');
|
||||
confirmSheet('Remove it from Plex too?', '“' + title + '” is now a Family playlist. Cancel keeps the Plex one.', 'Remove from Plex', async () => { try { await plx.del(key); } catch (e) { toast(e.message); } location.hash = '#/music/family/' + r.id; });
|
||||
} catch (e) { toast(e.message); }
|
||||
}, title) : toast('This playlist has no songs');
|
||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
||||
}
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
|
||||
+21
-15
@@ -195,12 +195,12 @@ function renderMini() {
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
||||
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
|
||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = () => vizOpen(); $('#mq').onclick = openQueue; $('#mz').onclick = () => sleepSheet();
|
||||
// tap or drag along the thin bar to seek
|
||||
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||
@@ -221,8 +221,8 @@ function stopPlayer() {
|
||||
}
|
||||
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||
function modeUI() {
|
||||
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
||||
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||
document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
||||
document.querySelectorAll('#mr, #npr, #cnr, #fsr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||
}
|
||||
function syncUI() {
|
||||
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||
@@ -246,15 +246,18 @@ function syncUI() {
|
||||
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
||||
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
||||
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
||||
const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT;
|
||||
const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
|
||||
const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000);
|
||||
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
||||
}
|
||||
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
|
||||
// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls).
|
||||
function renderNpCard() {
|
||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
|
||||
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
|
||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
|
||||
const host = onHome ? $('#npslot') : null;
|
||||
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
||||
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
||||
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
|
||||
if (!host.contains(el)) host.appendChild(el);
|
||||
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
||||
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||
@@ -273,22 +276,25 @@ function renderNpCard() {
|
||||
function updateNow() {
|
||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
||||
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
|
||||
const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1);
|
||||
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||
modeUI();
|
||||
}
|
||||
function openNow() {
|
||||
const t = cur(); if (!t) return;
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></div>
|
||||
<div class="npseek"><input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div></div>
|
||||
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
|
||||
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}</div></div>`);
|
||||
el.querySelector('.sheet').classList.add('npsheet');
|
||||
const sk = el.querySelector('#npk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||
el.querySelector('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); };
|
||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
|
||||
+10
-4
@@ -7,6 +7,8 @@ const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateSt
|
||||
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
||||
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
||||
const sgOnView = () => location.hash.startsWith('#/signal');
|
||||
/** Where a Signal conversation opens: inside Chat when the person has both (the Chat list shows Signal chats too), otherwise the Signal screen. */
|
||||
const sgHref = id => typeof sgInChat === 'function' && sgInChat() ? '#/chat/' + encodeURIComponent('sg:' + id) : '#/signal/' + encodeURIComponent(id);
|
||||
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
||||
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
||||
if (!allowed('signal') || sg.es) return;
|
||||
@@ -38,7 +40,7 @@ function sgAlert(ev) { // a message from someone else arrived and you are not lo
|
||||
if (a.sound) sgChime();
|
||||
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
|
||||
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
|
||||
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
||||
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView() || sg.inChat) location.hash = sgHref(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
||||
}
|
||||
}
|
||||
let sgEvT = null;
|
||||
@@ -49,6 +51,9 @@ function sgEvent(ev) {
|
||||
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
|
||||
}
|
||||
if (ev.t === 'gone') sgGone(ev.conv, true);
|
||||
if (sg.inChat) { // a Signal conversation is open inside Chat: refresh it and the combined list, never turn the Chat screen into the Signal screen
|
||||
if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (sg.cur && ev.t !== 'status') sgDrawThread(false); if (typeof chSigChanged === 'function') chSigChanged(); }, 150); return;
|
||||
}
|
||||
if (!sgMounted()) return;
|
||||
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
||||
}
|
||||
@@ -57,6 +62,7 @@ function sgEvent(ev) {
|
||||
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
||||
// ... and the same screens in a panel that floats over every other screen.
|
||||
async function sgMount(root, dock) {
|
||||
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
|
||||
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
|
||||
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
||||
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
@@ -114,7 +120,7 @@ async function sgDrawThread(first) {
|
||||
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
||||
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
||||
if (!box.querySelector('.sgbar')) {
|
||||
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
||||
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="${sg.inChat ? '#/chat' : '#/signal'}" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
||||
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
||||
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
||||
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
||||
@@ -186,7 +192,7 @@ function sgChatMenu(id) {
|
||||
}
|
||||
function sgGone(id, fromServer) {
|
||||
sg.convs = sg.convs.filter(x => x.id !== id);
|
||||
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
|
||||
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sg.inChat) { location.hash = '#/chat'; } else if (sgOnView()) location.hash = '#/signal'; }
|
||||
if (!fromServer || sgMounted()) sgRefresh();
|
||||
}
|
||||
function sgEditContact(c, after) {
|
||||
@@ -205,7 +211,7 @@ async function sgNewChat() {
|
||||
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
|
||||
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
|
||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
|
||||
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
||||
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = sgHref(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
||||
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
||||
}
|
||||
function sgLinkScreen(root, dock) {
|
||||
|
||||
@@ -1111,3 +1111,41 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
|
||||
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
|
||||
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
|
||||
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
|
||||
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
|
||||
|
||||
/* Now Playing sheet: cover, title, seek, controls, and a row of extras */
|
||||
#sheet .sheet.npsheet h3:empty { display: none; }
|
||||
#sheet .sheet.npsheet { padding-top: 14px; }
|
||||
.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); }
|
||||
.np .npmeta { padding: 0 8px; }
|
||||
.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.np .npseek { margin: 16px 0 0; }
|
||||
.np .ctl { gap: 8px; margin: 6px 0 14px; }
|
||||
.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; }
|
||||
.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; }
|
||||
.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; }
|
||||
.npchip.on { color: var(--danger); border-color: currentColor; }
|
||||
/* full screen: art and words side by side, the player along the bottom */
|
||||
#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; }
|
||||
#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; }
|
||||
#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; }
|
||||
#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; }
|
||||
#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; }
|
||||
#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; }
|
||||
#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; }
|
||||
#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); }
|
||||
#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; }
|
||||
#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; }
|
||||
@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } }
|
||||
|
||||
/* no lyrics: the queue fills the space */
|
||||
.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; }
|
||||
.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||
.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); }
|
||||
.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); }
|
||||
.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; }
|
||||
#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v133';
|
||||
const CACHE = 'hub-v134';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
Reference in new issue
Block a user