Merge branch 'refs/heads/worktree-chat-share-cards'
build-container / build (push) Successful in 3m20s

# Conflicts:
#	public/sw.js
This commit is contained in:
nmemmert committed 2026-10-27 09:16:03 -04:00
commit 7b708a711c
11 files changed
+87 -27

No files matched your search

+28 -13
View File
@@ -17,6 +17,19 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
const fail = (code, message) => Object.assign(new Error(message), { code }); const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n); const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(8).toString('hex'); const newId = () => crypto.randomBytes(8).toString('hex');
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'] };
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
function cleanShare(s) {
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
if (!title) throw fail(400, 'Nothing to share');
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
if (art && (art.length > 600 || !ART_OK.test(art) || art.includes('..'))) throw fail(400, 'That picture cannot be shared');
return { kind: s.kind, title, sub: str(s.sub, 120).trim(), art, link };
}
const shareLine = s => SHARE[s.kind][0] + ' Shared ' + SHARE[s.kind][1] + ': ' + s.title;
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || ''); const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
// ---- storage (small enough to hold in memory and write whole) // ---- storage (small enough to hold in memory and write whole)
@@ -44,9 +57,10 @@ const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone';
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); }; const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name })); const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; }; const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
const textOf = m => [m.text, m.share && m.share.title, m.share && m.share.sub].filter(Boolean).join(' · ');
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; }; const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); }; const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : ''; const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.share ? SHARE[m.share.kind][0] + ' ' + m.share.title : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
// ---- live updates (server-sent events), one stream per open app // ---- live updates (server-sent events), one stream per open app
const annFile = () => path.join(ROOT(), 'announcement.json'); const annFile = () => path.join(ROOT(), 'announcement.json');
@@ -60,7 +74,7 @@ const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */ /** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; }; const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)])); const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) }); const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, share: m.deleted ? undefined : m.share, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
/** Someone has received or read the chat up to now: the senders' ticks change. */ /** Someone has received or read the chat up to now: the senders' ticks change. */
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } } function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off'; const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
@@ -78,9 +92,10 @@ const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid =
function send(me, b) { function send(me, b) {
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = []; const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); } for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
if (!text && !att.length) throw fail(400, 'Write something or attach a file'); const share = b.share ? cleanShare(b.share) : undefined;
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; } if (!text && !att.length && !share) throw fail(400, 'Write something or attach a file');
return addMsg(me, c, { text, att, quote }); let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.share ? q.share.title : q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
return addMsg(me, c, { text, att, quote, share });
} }
/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */ /** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */
function markRead(uid, c) { function markRead(uid, c) {
@@ -89,10 +104,10 @@ function markRead(uid, c) {
inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id });
} }
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */ /** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) { function addMsg(me, c, { text, att = [], quote, fwd, sys, poll, share }) {
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined }; const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, share, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles(); list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name; const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : share ? shareLine(share) : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) })); emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered) for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }); if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
@@ -182,7 +197,7 @@ async function handle(req, res) {
if ((p === '/edit' || p === '/delete') && req.method === 'POST') { if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; } if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); } else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.share = undefined; x.react = {}; saveFiles(); }
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
} }
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); } if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
@@ -219,11 +234,11 @@ async function handle(req, res) {
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean); const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) }); if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, share: x.share, fwd: { name: nameOf(x.from) } }) });
} }
if (p === '/search' && req.method === 'GET') { if (p === '/search' && req.method === 'GET') {
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id); const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) }); return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
} }
return json(res, 404, { error: 'Not found' }); return json(res, 404, { error: 'Not found' });
@@ -242,7 +257,7 @@ function askParents(me, text) {
/** Messages in this person's chats that mention the words. */ /** Messages in this person's chats that mention the words. */
function searchFor(me, q) { function searchFor(me, q) {
const needle = q.toLowerCase(), out = [], st = stateOf(me.id); const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8); return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
} }
module.exports = { markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; module.exports = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
+3 -1
View File
@@ -92,15 +92,17 @@ async function viewBooks(args = []) {
const byId = new Map(abBooks.map(b => [abId(b), b])); 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)); }; v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
lvBind(v, () => route()); 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) { 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 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> 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 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; } }; const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
el.querySelector('#abp').onclick = () => go(); 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); if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
el.querySelector('#abc').onclick = async () => { el.querySelector('#abc').onclick = async () => {
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>'; const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
+24 -5
View File
@@ -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 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 : []; 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('') : ''}` : '') 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>`; 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(); }; 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('')} ${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>`; <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) { function chBubble(m, c, prev) {
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id; 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>` 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>`; 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>` : ''; 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>` : ''} 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.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>` : ''} ${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> <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() { function chDrawReply() {
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; } 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(); }; chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
} }
function chDrawTray() { function chDrawTray() {
@@ -220,8 +230,8 @@ function chPaintSub() {
} }
function chDrawPin() { function chDrawPin() {
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; } 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>`; 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 || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`); 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-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); } }); }; 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>`); 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); } }); 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) // ---- settings: quiet hours, and the family announcement (admin)
async function chSettings() { async function chSettings() {
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); } let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
+2 -1
View File
@@ -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> 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> <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>` : ''} ${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(); }; 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(); 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('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); }; 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])); }; 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
View File
@@ -155,8 +155,9 @@ async function musicRoute([kind = 'featured', key, key2], st) {
if (kind === 'artist') { 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 || ''; 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> 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>`; <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; return;
} }
if (kind === 'album' || kind === 'playlist') { 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> 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> <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>` : ''} ${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> <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> <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>`; <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); $('#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); } }); $('#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); $('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true); $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying(); markPlaying();
+1 -1
View File
@@ -112,7 +112,7 @@ function trackSheet(t, extra) {
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [ ...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) }, { 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]) }, { 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; } }] : []), ...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
] : []), ] : []),
...(extra || []), ...(extra || []),
+4 -2
View File
@@ -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 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> 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> <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> $('#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="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> <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 => { document.querySelectorAll('.pdep').forEach(r => {
const e = byId.get(r.dataset.id); const e = byId.get(r.dataset.id);
r.querySelector('[data-play]').onclick = () => playFrom(e); 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; }; 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); }; 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 () => { r.querySelector('[data-seen]').onclick = async () => {
@@ -139,9 +140,10 @@ async function pdShow(id) {
}); });
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]); if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
$('#pdmore').onclick = () => { $('#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="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>`); <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); } }; 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('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused'); act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
+3
View File
@@ -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; } .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 { 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; } .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 { 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); } .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; } } .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
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // 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 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+3 -1
View File
@@ -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="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>' : ''}` <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 === '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> <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>` : ''} ${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>`; <div id="vkids"></div></div>`;
@@ -196,6 +196,8 @@ async function videoItem(id) {
}).catch(() => {}); }).catch(() => {});
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour 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 (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 ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); 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}`; }; if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
+13
View File
@@ -83,6 +83,19 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot &amp; Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words'); const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot &amp; Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null); assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
// ---- share cards: a song, film or book from the hub, with a picture and a link back
const card = { kind: 'song', title: 'Blue Moon', sub: 'Sinatra', art: '/plex/photo/:/transcode?width=300&url=%2Flibrary%2Fa', link: '#/music/album/123' };
for (const bad of [{ ...card, kind: 'virus' }, { ...card, title: ' ' }, { ...card, link: 'https://evil.example/x' }, { ...card, link: 'javascript:alert(1)' }, { ...card, link: '#/music/<b>' }, { ...card, art: 'https://evil.example/a.jpg' }, { ...card, art: '//evil.example/a.jpg' }, { ...card, art: '/plex/../etc/passwd' }]) assert.equal((await post('/send', { conv: g2.id, share: bad }, 'a')).status, 400, 'a bad card is refused: ' + JSON.stringify(bad));
assert.equal((await post('/send', { conv: g2.id, share: card }, 'c')).status, 200, 'sharing needs no words'); await post('/read', { id: g2.id }, 'b');
const sh = (await j(await post('/send', { conv: g2.id, text: 'Listen to this', share: { ...card, title: 'T'.repeat(500), sub: 'S'.repeat(500) } }, 'a'))).msg; assert.equal(sh.share.title.length, 120, 'long words are cut'); assert.equal(sh.share.sub.length, 120); assert.equal(sh.text, 'Listen to this', 'with a note');
assert.equal((await j(await call('/convs', {}, 'b'))).unread, 1, 'a share counts as unread (the earlier one was read)'); assert.match((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, /^Ann: 🎵 TTT/, 'listed by its title');
const sh2 = (await j(await post('/send', { conv: g2.id, share: { kind: 'podcastep', title: 'Ep 4', link: '#/podcast/show/x' } }, 'a'))).msg; assert.equal(sh2.share.art, '', 'the picture is optional'); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share.link, '#/podcast/show/x');
assert.equal(chat.cleanShare({ kind: 'movie', title: 'Up', link: '#/video/item/9' }).title, 'Up');
assert.ok(require('../inbox').load(P.b.id).some(i => i.body === 'Ann: 🎙️ Shared a podcast episode: Ep 4' && i.url.startsWith('#/chat/')), 'the Notifications list says what was shared');
assert.equal((await j(await call('/search?q=sinatra', {}, 'b'))).results.length, 1, 'search finds a card by its subtitle'); assert.equal((await j(await call('/search?q=ep%204', {}, 'b'))).results.length, 1);
const fws = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: sh.id }, 'a'))).msg; assert.equal(fws.share.link, '#/music/album/123', 'a forwarded card stays a card');
assert.equal((await post('/delete', { conv: g2.id, msg: sh2.id }, 'a')).status, 200); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share, undefined, 'a deleted card shows nothing');
// ---- family extras: announcement and quiet hours // ---- family extras: announcement and quiet hours
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it'); assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');