Merge branch 'refs/heads/worktree-chat-share-cards'
build-container / build (push) Successful in 3m20s
build-container / build (push) Successful in 3m20s
# Conflicts: # public/sw.js
This commit is contained in:
commit
7b708a711c
11 files changed
+87
-27
No files matched your search
@@ -92,15 +92,17 @@ async function viewBooks(args = []) {
|
||||
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
||||
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||
lvBind(v, () => route());
|
||||
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
||||
}
|
||||
|
||||
function abSheet(b) {
|
||||
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
||||
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||
el.querySelector('#abp').onclick = () => go();
|
||||
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||
el.querySelector('#abc').onclick = async () => {
|
||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
|
||||
+24
-5
@@ -62,7 +62,7 @@ function chDrawList() {
|
||||
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
|
||||
const found = q.length >= 2 && ch.found ? ch.found : [];
|
||||
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
|
||||
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||
|| '';
|
||||
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
|
||||
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
|
||||
@@ -78,6 +78,16 @@ function chPoll(m) {
|
||||
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
|
||||
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
|
||||
}
|
||||
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
|
||||
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
|
||||
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
|
||||
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
|
||||
function chShareCard(m) {
|
||||
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
|
||||
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
|
||||
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
|
||||
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
||||
}
|
||||
function chBubble(m, c, prev) {
|
||||
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
|
||||
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
||||
@@ -89,7 +99,7 @@ function chBubble(m, c, prev) {
|
||||
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
|
||||
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
|
||||
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
|
||||
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.poll ? chPoll(m) : ''}${m.share && !m.deleted ? chShareCard(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
|
||||
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
|
||||
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
|
||||
@@ -139,7 +149,7 @@ function chMessageMenu(m, c) {
|
||||
}
|
||||
function chDrawReply() {
|
||||
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || (ch.reply.share && ch.reply.share.title) || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
|
||||
}
|
||||
function chDrawTray() {
|
||||
@@ -220,8 +230,8 @@ function chPaintSub() {
|
||||
}
|
||||
function chDrawPin() {
|
||||
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.share && last.share.title) || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || (m.share && m.share.title) || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
|
||||
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
|
||||
}
|
||||
@@ -325,6 +335,15 @@ async function shareToChat(text) {
|
||||
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(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)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
/** From a song, film, book...: pick a chat, add a note if you like, and a share card goes in. card = { kind, title, sub, art, link } (art and link are paths inside the hub). */
|
||||
async function shareMedia(card) {
|
||||
if (!allowed('chat')) return toast('Chat is not turned on for you');
|
||||
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
|
||||
const k = CH_SHARE[card.kind] || ['🔗', 'Shared'];
|
||||
const el = openSheet('Share to Chat', k[0] + ' ' + card.title, `<label for="chsn">Add a note (optional)</label><input type="text" id="chsn" maxlength="300" autocomplete="off" placeholder="You will love this">
|
||||
<div class="list" style="max-height:44dvh;overflow:auto;margin-top:10px">${convs.map(c => `<button class="row" data-t="${esc(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)}</b></span>${icon('send')}</button>`).join('') || '<p class="empty">Start a chat first (Chat, then New).</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const text = el.querySelector('#chsn').value, to = b.dataset.t; closeSheet(); try { await chPost('/send', { conv: to, text, share: { kind: card.kind, title: card.title, sub: card.sub || '', art: /^\/(plex|api)\/[^\s"'<>\\]*$/.test(card.art || '') && !card.art.includes('..') ? card.art : '', link: card.link } }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(to); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
// ---- settings: quiet hours, and the family announcement (admin)
|
||||
async function chSettings() {
|
||||
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
||||
|
||||
@@ -85,9 +85,10 @@ async function lvSheet(id, after, known = new Map()) {
|
||||
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button>${allowed('chat') ? `<button class="btn ghost" id="lvh">${icon('send')}Share</button>` : ''}</div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||
el.querySelector('#lvp').onclick = () => go();
|
||||
if (el.querySelector('#lvh')) el.querySelector('#lvh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: b.title, sub: b.author || '', link: '#/audio/book/lv:' + id }); }; // (no picture: LibriVox covers live on another site)
|
||||
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||
|
||||
+5
-2
@@ -155,8 +155,9 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
if (kind === 'artist') {
|
||||
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a>${allowed('chat') ? `<button class="btn ghost" id="ash">${icon('send')}Share</button>` : ''}</div>
|
||||
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
||||
if ($('#ash')) $('#ash').onclick = () => shareMedia({ kind: 'artist', title: mc.title2 || mc.parentTitle || 'Artist', art: px.art((am.Metadata || [])[0] && am.Metadata[0].thumb, 200), link: '#/music/artist/' + key });
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' || kind === 'playlist') {
|
||||
@@ -171,7 +172,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>' : ''}${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="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 +183,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||
$('#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 });
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||
markPlaying();
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ function trackSheet(t, extra) {
|
||||
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
|
||||
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
|
||||
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareMedia({ kind: 'song', title: t.title, sub: t.artist, art: px.art(t.thumb, 200), link: '#/music/album/' + t.albumKey }) }] : []),
|
||||
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
||||
] : []),
|
||||
...(extra || []),
|
||||
|
||||
@@ -114,7 +114,7 @@ async function pdShow(id) {
|
||||
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
${allowed('chat') ? `<button class="ibtn" data-share aria-label="Share to Chat" title="Share to Chat">${icon('send')}</button>` : ''}<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
||||
@@ -131,6 +131,7 @@ async function pdShow(id) {
|
||||
document.querySelectorAll('.pdep').forEach(r => {
|
||||
const e = byId.get(r.dataset.id);
|
||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||
const shb = r.querySelector('[data-share]'); if (shb) shb.onclick = () => shareMedia({ kind: 'podcastep', title: pdF(e, 'title'), sub: pdF(show, 'title'), art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) });
|
||||
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
|
||||
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
|
||||
r.querySelector('[data-seen]').onclick = async () => {
|
||||
@@ -139,9 +140,10 @@ async function pdShow(id) {
|
||||
});
|
||||
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
||||
$('#pdmore').onclick = () => {
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list">${allowed('chat') ? `<button class="row" id="pdsh"><span class="grow"><b>Share to Chat</b></span></button>` : ''}<button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
||||
const sh = el.querySelector('#pdsh'); if (sh) sh.onclick = () => { closeSheet(); shareMedia({ kind: 'podcast', title: pdF(show, 'title'), sub: pdF(show, 'author') || '', art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) }); };
|
||||
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
||||
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
||||
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
||||
|
||||
@@ -1025,6 +1025,9 @@ body.vizopen { overflow: hidden; }
|
||||
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
|
||||
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
|
||||
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
|
||||
.sgshare { display: flex; align-items: center; gap: 10px; margin: 2px 0 4px; padding: 8px; min-width: 220px; border-radius: 12px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgshare.off { opacity: .75; }
|
||||
.sgshare-art { flex: none; width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: rgba(127,127,127,.25); display: grid; place-items: center; font-size: 26px; font-style: normal; }
|
||||
.sgshare-t { min-width: 0; flex: 1; } .sgshare-t small, .sgshare-t b, .sgshare-t em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: normal; } .sgshare-t small { opacity: .7; } .sgshare-t em { opacity: .8; font-size: .9em; } .sgshare-t .sgshare-off { opacity: 1; color: var(--danger); font-weight: 600; margin-top: 2px; } .sgshare-go { font-size: 22px; opacity: .6; }
|
||||
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
|
||||
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
|
||||
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v94';
|
||||
const CACHE = 'hub-v95';
|
||||
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'];
|
||||
|
||||
|
||||
+3
-1
@@ -170,7 +170,7 @@ async function videoItem(id) {
|
||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
${allowed('chat') && m.type !== 'collection' ? `<button class="btn ghost" id="vshare">${icon('send')}Share</button>` : ''}${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
<p class="vsum">${esc(m.summary || '')}</p>
|
||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||
<div id="vkids"></div></div>`;
|
||||
@@ -196,6 +196,8 @@ async function videoItem(id) {
|
||||
}).catch(() => {});
|
||||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||
|
||||
Reference in new issue
Block a user