Compare commits
16
Commits
pantry-scan
...
main
No files matched your search
@@ -35,6 +35,7 @@ jobs:
|
|||||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||||
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
|
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
|
||||||
|
- run: node tools/chatextras-test.js # chat: GIFs (family-friendly filter, kept in the chat) and sharing a place
|
||||||
- run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry
|
- run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry
|
||||||
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ playing an episode or pressing the button still works. Settings -> Health shows
|
|||||||
|
|
||||||
- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once.
|
- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once.
|
||||||
- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time.
|
- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time.
|
||||||
|
- **Chat extras**: a **GIF** button (with no key it uses Wikimedia Commons through MediaWiki's search API: free animated pictures of animals, nature, space and science; set `TENOR_API_KEY` or `GIPHY_API_KEY` for reaction GIFs instead, or `GIF_PROVIDER=wikimedia|tenor|giphy|off` to choose; the hub does all the talking to the service, asks only for G-rated results, refuses searches and drops results containing words that could bring up adult material, keeps a sent GIF in the chat like a photo, and a sender or admin can delete one that slips through), **Place or location** (your phone's location after it asks, or a place search through OpenStreetMap; shared as a card whose link is an Apple Maps link, `maps.apple.com`, which opens the Maps app on an iPhone or iPad), and the Chat list also shows **Signal** conversations, labelled, on screens where Signal is available.
|
||||||
- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen.
|
- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen.
|
||||||
- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported.
|
- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
|
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
|
||||||
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
|
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
|
||||||
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
|
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
|
||||||
|
const gifs = require('./gifs');
|
||||||
|
let placeAt = 0;
|
||||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
const atomicWrite = require('./atomic');
|
const atomicWrite = require('./atomic');
|
||||||
@@ -18,11 +20,16 @@ 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
|
// ---- 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 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'], place: ['📍', 'a place'] };
|
||||||
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
|
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). */
|
/** 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) {
|
function cleanShare(s) {
|
||||||
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
|
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
|
||||||
|
if (s.kind === 'place') { // a place on the map: the coordinates are checked and the link is made here (Apple Maps), never taken from the sender
|
||||||
|
const lat = Math.round(+s.lat * 1e5) / 1e5, lng = Math.round(+s.lng * 1e5) / 1e5, title = str(s.title, 120).trim() || 'Shared location';
|
||||||
|
if (!(Math.abs(lat) <= 90) || !(Math.abs(lng) <= 180) || s.lat === '' || s.lng === '' || s.lat == null || s.lng == null) throw fail(400, 'That place has no location');
|
||||||
|
return { kind: 'place', title, sub: str(s.sub, 160).trim(), art: '', link: `https://maps.apple.com/?ll=${lat},${lng}&q=${encodeURIComponent(title)}`, lat, lng };
|
||||||
|
}
|
||||||
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
|
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
|
||||||
if (!title) throw fail(400, 'Nothing to share');
|
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 (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
|
||||||
@@ -173,6 +180,23 @@ async function handle(req, res) {
|
|||||||
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
|
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
|
||||||
}
|
}
|
||||||
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
|
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
|
||||||
|
if (p === '/places' && req.method === 'GET') { // search for a place by name (OpenStreetMap's Nominatim, one request a second at most, asked from here)
|
||||||
|
const term = str(q.get('q'), 100).trim(); if (term.length < 3) throw fail(400, 'Type at least three letters');
|
||||||
|
const wait = Math.max(0, placeAt + 1100 - Date.now()); placeAt = Date.now() + wait; if (wait) await new Promise(r => setTimeout(r, wait));
|
||||||
|
let j; try { const r = await fetch(`${(process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org').replace(/\/+$/, '')}/search?format=jsonv2&limit=6&addressdetails=0&q=${encodeURIComponent(term)}`, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)', accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error('status ' + r.status); j = await r.json(); } catch { throw fail(502, 'The map search did not answer. Try again in a moment.'); }
|
||||||
|
return json(res, 200, { items: (Array.isArray(j) ? j : []).filter(x => isFinite(+x.lat) && isFinite(+x.lon)).slice(0, 6).map(x => { const parts = String(x.display_name || '').split(', '); return { title: str(x.name || parts[0] || term, 120), sub: str(parts.slice(1, 4).join(', '), 160), lat: +x.lat, lng: +x.lon }; }) });
|
||||||
|
}
|
||||||
|
if (p === '/gif/status') return json(res, 200, { enabled: gifs.enabled(), provider: gifs.provider() });
|
||||||
|
if (p === '/gif' && req.method === 'GET') { try { return json(res, 200, await gifs.search(q.get('q'), q.get('pos'))); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); } }
|
||||||
|
if (p === '/gifimg' && req.method === 'GET') { // a preview, fetched here so the phone never contacts the GIF service
|
||||||
|
try { const g = await gifs.fetchBytes(String(q.get('u') || ''), 2 * 1024 * 1024); res.writeHead(200, { 'content-type': g.type, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff', 'content-length': g.buf.length }); return res.end(g.buf); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
|
||||||
|
}
|
||||||
|
if (p === '/gif/send' && req.method === 'POST') { // the chosen GIF is downloaded and kept in the chat like a photo
|
||||||
|
const b = JSON.parse(await readBody(req, 5e3) || '{}'), c = convOf(String(b.conv || ''), me); let g; try { g = await gifs.fetchBytes(String(b.url || '')); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
|
||||||
|
const id = newId(); fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); fs.writeFileSync(blob(id), g.buf, { mode: 0o600 });
|
||||||
|
files()[id] = { conv: c.id, by: me.id, name: (str(b.title, 60).replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').trim() || 'GIF') + (g.type === 'image/gif' ? '.gif' : '.' + g.type.slice(6)), type: g.type, size: g.buf.length, ts: Date.now() }; saveFiles();
|
||||||
|
return json(res, 200, { msg: send(me, { conv: c.id, files: [id] }) });
|
||||||
|
}
|
||||||
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
|
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
|
||||||
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
|
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
|
||||||
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
|
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-}
|
PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-}
|
||||||
MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server
|
MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server
|
||||||
MEALIE_TOKEN: ${MEALIE_TOKEN:-}
|
MEALIE_TOKEN: ${MEALIE_TOKEN:-}
|
||||||
|
TENOR_API_KEY: ${TENOR_API_KEY:-} # GIFs in Family Chat work with no key (Wikimedia Commons). For reaction GIFs, a Tenor key (Google Cloud, Tenor API) ...
|
||||||
|
GIPHY_API_KEY: ${GIPHY_API_KEY:-} # ... or a GIPHY key; either replaces Commons. GIF_PROVIDER=off turns the button off. Tenor and GIPHY results are G-rated only; Commons has no ratings, so its results are checked by words and categories.
|
||||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
||||||
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
||||||
|
|||||||
+3
-3
@@ -22,12 +22,12 @@ A messenger inside the hub for the family, so family chats do not need Signal. E
|
|||||||
- [x] Disappearing messages (a timer per chat)
|
- [x] Disappearing messages (a timer per chat)
|
||||||
- [x] @mentions in groups, pinned messages, forward a message
|
- [x] @mentions in groups, pinned messages, forward a message
|
||||||
|
|
||||||
**Phase 3: richer messages (M): done except the GIF picker and shared places**
|
**Phase 3: richer messages (M): done**
|
||||||
- [x] Voice notes (record and play, with a waveform)
|
- [x] Voice notes (record and play, with a waveform)
|
||||||
- [x] Link previews (fetched safely by the server)
|
- [x] Link previews (fetched safely by the server)
|
||||||
- [x] Polls
|
- [x] Polls
|
||||||
- [ ] GIF or sticker picker (optional)
|
- [x] GIF picker: **Done (gifs.js, chat-extras.js): works with no key through Wikimedia Commons (MediaWiki search), or with a Tenor or GIPHY key; a blocklist on searches and results (and categories for Commons), previews and the sent GIF go through the hub.**
|
||||||
- [ ] Share a place as a link
|
- [x] Share a place as a link: **Done: your location (the phone asks) or a place search through OpenStreetMap, shown as a card with an Apple Maps (maps.apple.com) link.**
|
||||||
|
|
||||||
**Family extras (M): mostly done**
|
**Family extras (M): mostly done**
|
||||||
- [x] Turn a message into a task, an event or a shopping item in one tap
|
- [x] Turn a message into a task, an event or a shopping item in one tap
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY), or with no key at all from Wikimedia Commons (MediaWiki's search API): free animated
|
||||||
|
// pictures of animals, space, science and nature rather than reaction memes. GIF_PROVIDER=tenor|giphy|wikimedia|off picks one; the default is the first of Tenor, GIPHY, Wikimedia that works. The hub does all the talking to the service, so phones never contact it:
|
||||||
|
// searches come back through here, previews are fetched here, and a GIF that is sent is downloaded and kept in the chat like any other picture.
|
||||||
|
// Everything is kept family-friendly, for everybody: only the services' G-rated results are asked for, words that could bring up adult material are refused in a search and in what comes
|
||||||
|
// back (title and tags), and only GIFs this server itself listed can be sent. A filter like this is a strong guard, not a promise: a sender or an admin can delete anything that slips through.
|
||||||
|
const TENOR = () => (process.env.TENOR_URL || 'https://tenor.googleapis.com').replace(/\/+$/, ''), GIPHY = () => (process.env.GIPHY_URL || 'https://api.giphy.com').replace(/\/+$/, '');
|
||||||
|
const WIKI = () => (process.env.WIKIMEDIA_URL || 'https://commons.wikimedia.org').replace(/\/+$/, '');
|
||||||
|
const provider = () => { const w = String(process.env.GIF_PROVIDER || '').toLowerCase(); if (w === 'off') return ''; if (w === 'wikimedia' || w === 'tenor' && process.env.TENOR_API_KEY || w === 'giphy' && process.env.GIPHY_API_KEY) return w; return process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : 'wikimedia'; };
|
||||||
|
const enabled = () => !!provider();
|
||||||
|
|
||||||
|
// whole words (and stems) that are never searched for and never shown
|
||||||
|
const BLOCK = ['sex', 'sexy', 'sexual', 'sexually', 'sexier', 'sexiest', 'porn', 'porno', 'pornography', 'pornographic', 'xxx', 'nsfw', 'nude', 'nudes', 'nudity', 'naked', 'topless', 'bottomless', 'boob', 'boobs', 'boobies', 'breast', 'breasts', 'tit', 'tits', 'titty', 'titties', 'nipple', 'nipples', 'cleavage', 'butt', 'butts', 'booty', 'twerk', 'twerking', 'thong', 'thongs', 'lingerie', 'bikini', 'bikinis', 'underwear', 'panties', 'bra', 'bras', 'strip', 'stripper', 'strippers', 'striptease', 'erotic', 'erotica', 'fetish', 'kink', 'kinky', 'bdsm', 'hentai', 'orgasm', 'orgasms', 'penis', 'vagina', 'dick', 'dicks', 'cock', 'cocks', 'pussy', 'pussies', 'cum', 'cumming', 'horny', 'slut', 'sluts', 'whore', 'hooker', 'hookers', 'masturbate', 'masturbation', 'blowjob', 'handjob', 'anal', 'milf', 'onlyfans', 'playboy', 'adult', 'adults', 'lust', 'seduce', 'seductive', 'foreplay', 'grope', 'groping', 'fuck', 'fucking', 'fucker', 'fucked', 'shit', 'bitch', 'bitches', 'asshole', 'ass', 'drugs', 'cocaine', 'marijuana', 'gore', 'suicide'];
|
||||||
|
const BLOCKED = new Set(BLOCK);
|
||||||
|
// Commons files carry categories, so those are checked too, against a few more phrases (a category is a phrase, not a word)
|
||||||
|
const RISKY = /nudity|nude|naked|sexual|sexuality|erotic|pornograph|genital|fetish|bdsm|topless|underwear|swimwear|bikini|lingerie|breast|buttock|nipple|prostitut|obscene|sex education|human sexuality|anatomy of the|violence|corpse|dead bod|execution|torture|massacre|gore|suicide|drug use|cannabis|cocaine|heroin/i;
|
||||||
|
/** Does this text (a search, a title, tags) contain a word that is not allowed? Whole words only, so "class" and "assistant" are fine. */
|
||||||
|
const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []).some(w => BLOCKED.has(w) || BLOCKED.has(w.replace(/s$/, '')));
|
||||||
|
|
||||||
|
// only GIFs this server has just listed can be sent or previewed (so the download address cannot be chosen by the sender)
|
||||||
|
const known = new Map(); // url -> expiry
|
||||||
|
const remember = urls => { const t = Date.now() + 3600e3; for (const u of urls) known.set(u, t); if (known.size > 4000) for (const [k, v] of known) if (v < Date.now()) known.delete(k); };
|
||||||
|
const isKnown = u => (known.get(u) || 0) > Date.now();
|
||||||
|
const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com|upload\.wikimedia\.org)\//i;
|
||||||
|
const mediaOk = u => { try { const x = new URL(u); return MEDIA.test(u) || String(process.env.GIF_MEDIA_HOSTS || '').split(',').filter(Boolean).includes(x.host); } catch { return false; } };
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(8000) });
|
||||||
|
if (r.status === 403 || r.status === 401) throw Object.assign(new Error('The GIF service refused the key. Check it in the stack settings.'), { code: 502 });
|
||||||
|
if (r.status === 429) throw Object.assign(new Error('The GIF service is busy. Try again in a minute.'), { code: 429 });
|
||||||
|
if (!r.ok) throw Object.assign(new Error('The GIF service did not answer (' + r.status + ')'), { code: 502 });
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
/** Search (or, with no words, what is popular). Returns { items: [{ title, w, h, preview, url }], next }. */
|
||||||
|
async function search(q, pos) {
|
||||||
|
q = String(q || '').trim().slice(0, 60);
|
||||||
|
if (q && unsafe(q)) throw Object.assign(new Error('That search is not available. Try something else.'), { code: 400 });
|
||||||
|
const n = 24; let items = [], next = '';
|
||||||
|
if (provider() === 'tenor') {
|
||||||
|
const key = encodeURIComponent(process.env.TENOR_API_KEY), base = `${TENOR()}/v2/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'featured?'}key=${key}&client_key=nehub&limit=${n}&contentfilter=high&media_filter=tinygif,gif${pos ? '&pos=' + encodeURIComponent(pos) : ''}`;
|
||||||
|
const j = await getJson(base); next = j.next || '';
|
||||||
|
items = (j.results || []).map(x => { const t = (x.media_formats || {}).tinygif, g = (x.media_formats || {}).gif || t; return t && g ? { title: x.content_description || '', tags: (x.tags || []).join(' '), w: (t.dims || [])[0] || 0, h: (t.dims || [])[1] || 0, preview: t.url, url: g.url } : null; });
|
||||||
|
} else if (provider() === 'giphy') {
|
||||||
|
const off = Math.max(0, parseInt(pos, 10) || 0), key = encodeURIComponent(process.env.GIPHY_API_KEY), base = `${GIPHY()}/v1/gifs/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'trending?'}api_key=${key}&limit=${n}&offset=${off}&rating=g&bundle=messaging_non_clips`;
|
||||||
|
const j = await getJson(base); next = (j.data || []).length >= n ? String(off + n) : '';
|
||||||
|
items = (j.data || []).filter(x => !x.rating || x.rating === 'g').map(x => { const t = (x.images || {}).fixed_width_small || (x.images || {}).fixed_width, g = (x.images || {}).fixed_width || (x.images || {}).original; return t && g && t.url && g.url ? { title: x.title || '', tags: '', w: +t.width || 0, h: +t.height || 0, preview: t.url, url: g.url } : null; });
|
||||||
|
} else if (provider() === 'wikimedia') { // Wikimedia Commons: animated GIFs only, chosen by the search itself; no rating exists, so the word and category checks do the work
|
||||||
|
const off = Math.max(0, parseInt(pos, 10) || 0), term = (q || 'animal') + ' filemime:image/gif';
|
||||||
|
const url = `${WIKI()}/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=${n}&gsroffset=${off}&gsrsearch=${encodeURIComponent(term)}&prop=imageinfo&iiprop=url|size|mime|extmetadata&iiurlwidth=320&iiextmetadatafilter=ImageDescription|Categories|ObjectName`;
|
||||||
|
const j = await getJson(url); next = j.continue && j.continue.gsroffset != null ? String(j.continue.gsroffset) : '';
|
||||||
|
items = Object.values((j.query || {}).pages || {}).sort((a, b) => (a.index || 0) - (b.index || 0)).map(pg => {
|
||||||
|
const ii = (pg.imageinfo || [])[0]; if (!ii || ii.mime !== 'image/gif' || !ii.thumburl) return null; const em = ii.extmetadata || {}, plain = v => String(v && v.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const title = String(pg.title || '').replace(/^File:/, '').replace(/\.gif$/i, '').replace(/[_]+/g, ' '), cats = plain(em.Categories).replace(/\|/g, ' · '), desc = plain(em.ImageDescription).slice(0, 300);
|
||||||
|
if (RISKY.test(cats) || RISKY.test(title) || RISKY.test(desc)) return null; return { title: plain(em.ObjectName) || title, tags: cats + ' ' + desc, w: ii.thumbwidth || 0, h: ii.thumbheight || 0, preview: ii.thumburl, url: ii.thumburl };
|
||||||
|
});
|
||||||
|
} else throw Object.assign(new Error('GIFs are not set up on this server'), { code: 503 });
|
||||||
|
items = items.filter(x => x && mediaOk(x.preview) && mediaOk(x.url) && !unsafe(x.title) && !unsafe(x.tags)).map(({ tags, ...x }) => x);
|
||||||
|
remember(items.flatMap(x => [x.preview, x.url])); return { items, next };
|
||||||
|
}
|
||||||
|
/** The bytes of a GIF this server listed (a preview or the full one), size-limited. */
|
||||||
|
async function fetchBytes(url, max = 8 * 1024 * 1024) {
|
||||||
|
if (!isKnown(url) || !mediaOk(url)) throw Object.assign(new Error('That GIF is not available'), { code: 400 });
|
||||||
|
const r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(15000), redirect: 'error' });
|
||||||
|
if (!r.ok) throw Object.assign(new Error('The GIF could not be downloaded'), { code: 502 });
|
||||||
|
const type = String(r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!/^image\/(gif|webp|png|jpeg)$/.test(type)) throw Object.assign(new Error('That is not a picture'), { code: 502 });
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > max) throw Object.assign(new Error('That GIF is too large'), { code: 413 }); return { buf, type };
|
||||||
|
}
|
||||||
|
module.exports = { enabled, provider, search, fetchBytes, unsafe, isKnown, remember };
|
||||||
+13
-7
@@ -55,6 +55,9 @@ async function loadSettings() {
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
||||||
|
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
|
||||||
|
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
|
||||||
|
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
|
||||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||||
@@ -114,6 +117,7 @@ const ICONS = {
|
|||||||
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
||||||
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
|
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
|
||||||
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
||||||
|
pin: '<path d="M12 21s-7-6.2-7-11a7 7 0 0114 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
|
||||||
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
||||||
};
|
};
|
||||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||||
@@ -173,7 +177,9 @@ const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['musi
|
|||||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||||
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
|
const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
|
||||||
|
const chatUnread = () => (ch.unread || 0) + (sgInChat() ? (sg.unread || 0) : 0);
|
||||||
|
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0]) || (t[0] === 'signal' && sgInChat())) continue; let id = t[0], label = t[1];
|
||||||
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
||||||
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
||||||
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
||||||
@@ -201,13 +207,13 @@ function renderNav(active = activeTab) {
|
|||||||
if (typeof sgDockBadge === 'function') sgDockBadge();
|
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||||
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
||||||
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||||
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(chatUnread()) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
||||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||||
const mine = navTabs();
|
const mine = navTabs();
|
||||||
if (phone.matches) {
|
if (phone.matches) {
|
||||||
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
||||||
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
||||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(chatUnread()) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
||||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
||||||
} else {
|
} else {
|
||||||
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||||
@@ -218,7 +224,7 @@ phone.addEventListener('change', () => renderNav());
|
|||||||
|
|
||||||
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
||||||
function openLauncher() {
|
function openLauncher() {
|
||||||
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'chat' ? chatUnread() : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
||||||
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
||||||
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
||||||
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
||||||
@@ -254,7 +260,7 @@ function drawSubnav(name) {
|
|||||||
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
||||||
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
||||||
}
|
}
|
||||||
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||||
let curRoute = '';
|
let curRoute = '';
|
||||||
|
|
||||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||||
@@ -327,7 +333,7 @@ function homeApps() {
|
|||||||
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
||||||
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
|
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
|
||||||
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
|
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
|
||||||
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
|
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', chatUnread() ? chatUnread() + ' unread' : sgInChat() ? 'Family and Signal' : 'Family messages'),
|
||||||
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
|
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
|
||||||
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
|
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
|
||||||
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
|
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
|
||||||
@@ -339,7 +345,7 @@ function homeApps() {
|
|||||||
};
|
};
|
||||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||||
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewHome() {
|
function viewHome() {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Family Chat extras: a GIF picker (kept family-friendly by the hub; phones never contact the GIF service) and sharing a place (your location, or a place you search for).
|
||||||
|
// The Chat list also shows Signal conversations, labelled, when Signal is available on this screen.
|
||||||
|
async function chGifOn() { if (ch.gifOn === undefined) { try { const st = await chApi('/gif/status'); ch.gifOn = !!st.enabled; ch.gifBy = st.provider; } catch { ch.gifOn = false; } } return ch.gifOn; }
|
||||||
|
function chGifPicker() {
|
||||||
|
const to = ch.cur, by = ch.gifBy === 'giphy' ? 'GIPHY' : ch.gifBy === 'wikimedia' ? 'Wikimedia Commons (free pictures; mostly animals, nature, space and science)' : 'Tenor';
|
||||||
|
const el = openSheet('GIF', 'Family-friendly only', `<form id="gpf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="gpq" placeholder="Search GIFs" autocomplete="off" maxlength="60" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form>${ch.gifBy === 'wikimedia' ? `<div class="seg" id="gpc" style="flex-wrap:wrap">${['animals', 'space', 'weather', 'ocean', 'science', 'dance', 'cartoon', 'sports'].map(w => `<a href="#" data-w="${w}">${w}</a>`).join('')}</div>` : ''}<div class="gifgrid" id="gpg" role="list"></div><p class="err" id="gpe"></p><div class="bar" style="justify-content:center"><button class="btn ghost" id="gpm" hidden>More</button></div><p class="sub" style="text-align:center;margin:4px 0 0;font-size:12px">Powered by ${by}</p>`);
|
||||||
|
el.querySelector('.sheet').classList.add('wide'); let next = '', q = '', busy = false;
|
||||||
|
const load = async more => {
|
||||||
|
if (busy) return; busy = true; el.querySelector('#gpe').textContent = '';
|
||||||
|
try { const r = await chApi('/gif?q=' + encodeURIComponent(q) + (more && next ? '&pos=' + encodeURIComponent(next) : '')); next = r.next || ''; const g = el.querySelector('#gpg'); if (!more) g.innerHTML = '';
|
||||||
|
g.insertAdjacentHTML('beforeend', r.items.map(x => `<button type="button" class="gifc" role="listitem" data-u="${esc(x.url)}" data-t="${esc(x.title)}" aria-label="Send GIF ${esc(x.title || '')}"><img alt="${esc(x.title || 'GIF')}" loading="lazy" ${x.w && x.h ? `width="${x.w}" height="${x.h}"` : ''} src="/api/chat/gifimg?u=${encodeURIComponent(x.preview)}"></button>`).join('')); if (!more && !r.items.length) el.querySelector('#gpe').textContent = 'Nothing found. Try another word.';
|
||||||
|
el.querySelector('#gpm').hidden = !next; } catch (e) { el.querySelector('#gpe').textContent = e.message; } busy = false;
|
||||||
|
};
|
||||||
|
el.querySelector('#gpf').onsubmit = e => { e.preventDefault(); q = el.querySelector('#gpq').value.trim(); next = ''; load(false); };
|
||||||
|
el.querySelector('#gpm').onclick = () => load(true);
|
||||||
|
el.querySelectorAll('#gpc [data-w]').forEach(a => a.onclick = e => { e.preventDefault(); el.querySelector('#gpq').value = a.dataset.w; el.querySelector('#gpf').requestSubmit(); });
|
||||||
|
el.querySelector('#gpg').onclick = async e => { const b = e.target.closest('.gifc'); if (!b || b.disabled) return; b.disabled = true; b.style.opacity = '.5';
|
||||||
|
try { await chPost('/gif/send', { conv: to, url: b.dataset.u, title: b.dataset.t }); closeSheet(); } catch (er) { b.disabled = false; b.style.opacity = ''; el.querySelector('#gpe').textContent = er.message; } };
|
||||||
|
load(false);
|
||||||
|
}
|
||||||
|
function chPlacePicker() {
|
||||||
|
const to = ch.cur, el = openSheet('Share a place', '', `<div class="bar" style="justify-content:flex-start"><button class="btn" id="plme">📍 Send my location</button></div><p class="sub" id="plmm" style="margin:0 0 8px">Your phone asks first. This sends where you are right now, once; it does not keep following you.</p>
|
||||||
|
<form id="plf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="plq" placeholder="Or search for a place" autocomplete="off" maxlength="100" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form><div class="list" id="plr"></div><p class="err" id="ple"></p>`);
|
||||||
|
const send = async (title, sub, lat, lng) => { try { await chPost('/send', { conv: to, share: { kind: 'place', title, sub, lat, lng } }); closeSheet(); } catch (e) { el.querySelector('#ple').textContent = e.message; } };
|
||||||
|
el.querySelector('#plme').onclick = () => {
|
||||||
|
if (!navigator.geolocation) { el.querySelector('#ple').textContent = 'This browser cannot share a location here. Search for the place instead.'; return; }
|
||||||
|
el.querySelector('#plmm').textContent = 'Finding you…';
|
||||||
|
navigator.geolocation.getCurrentPosition(p => send('My location', `${p.coords.latitude.toFixed(4)}, ${p.coords.longitude.toFixed(4)}`, p.coords.latitude, p.coords.longitude), e => { el.querySelector('#plmm').textContent = e.code === 1 ? 'Location is blocked. Allow it for this site in your browser settings, or search for the place instead.' : 'Could not find your location. Search for the place instead.'; }, { enableHighAccuracy: true, timeout: 12000, maximumAge: 30000 });
|
||||||
|
};
|
||||||
|
el.querySelector('#plf').onsubmit = async e => { e.preventDefault(); const q = el.querySelector('#plq').value.trim(), box = el.querySelector('#plr'); el.querySelector('#ple').textContent = ''; if (q.length < 3) { el.querySelector('#ple').textContent = 'Type at least three letters.'; return; }
|
||||||
|
box.innerHTML = '<div class="row"><span class="grow"><small>Searching…</small></span></div>';
|
||||||
|
try { const r = await chApi('/places?q=' + encodeURIComponent(q)); box.innerHTML = r.items.map((x, i) => `<a href="#" class="row" data-i="${i}"><span class="grow" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc(x.sub)}</small></span></a>`).join('') || '<div class="row"><span class="grow"><small>No places found.</small></span></div>';
|
||||||
|
box.querySelectorAll('[data-i]').forEach(a => a.onclick = ev => { ev.preventDefault(); const x = r.items[+a.dataset.i]; send(x.title, x.sub, x.lat, x.lng); }); } catch (er) { box.innerHTML = ''; el.querySelector('#ple').textContent = er.message; } };
|
||||||
|
}
|
||||||
|
// ---- one list for Chat and Signal
|
||||||
|
async function chLoadSignal() {
|
||||||
|
ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return;
|
||||||
|
try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; }
|
||||||
|
}
|
||||||
|
const chSigRow = c => `<a class="row sgrow ${c.id === ch.curSig ? 'on' : ''}" data-sig="1" href="${sgHref(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
|
||||||
|
<span class="grow"><b>${esc(c.name)} <small class="srcbadge">Signal</small></b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`;
|
||||||
|
/** Signal changed (a message came in): redraw the Chat list if it is showing. */
|
||||||
|
function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); }
|
||||||
|
|
||||||
|
/** A Signal conversation shown in Chat's own conversation pane (so choosing one in the combined list never leaves Chat). */
|
||||||
|
async function chShowSignal() {
|
||||||
|
const box = chq('#chm'); if (!box) return;
|
||||||
|
if (!box.querySelector('#sgm')) { box.innerHTML = '<div id="sgm" class="sgin"></div>'; sg.reply = null; sg.tray = []; }
|
||||||
|
sg.root = ch.root; sg.dock = false; sg.inChat = true; sg.cur = ch.curSig;
|
||||||
|
await sgDrawThread(true);
|
||||||
|
}
|
||||||
+14
-10
@@ -34,19 +34,19 @@ function chEvent(ev) {
|
|||||||
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
|
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
|
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
|
||||||
async function chMount(root) {
|
async function chMount(root) {
|
||||||
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
||||||
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
||||||
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
||||||
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
||||||
await chRefresh(true);
|
await chRefresh(true);
|
||||||
}
|
}
|
||||||
async function chRefresh(first) {
|
async function chRefresh(first) {
|
||||||
try {
|
try {
|
||||||
const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
|
const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
|
||||||
if (!chq('#chlist')) return; chDrawList();
|
if (!chq('#chlist')) return; chDrawList();
|
||||||
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
if (ch.curSig && typeof chShowSignal === 'function') await chShowSignal(); else if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
||||||
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
async function chSearchMessages() {
|
async function chSearchMessages() {
|
||||||
@@ -61,7 +61,8 @@ function chDrawList() {
|
|||||||
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
|
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
|
||||||
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('') : ''}` : '')
|
const sig = (ch.sig || []).filter(match), rows = [...live.map(c => ({ ts: c.ts || 0, html: chRow(c) })), ...sig.map(c => ({ ts: c.ts || 0, html: chSigRow(c) }))].sort((a, b) => b.ts - a.ts);
|
||||||
|
box.innerHTML = rows.map(r => r.html).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 || (f.msg.share ? f.msg.share.title : ''))}</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>`;
|
||||||
@@ -79,13 +80,14 @@ function chPoll(m) {
|
|||||||
<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.
|
// ---- 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'] };
|
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'], place: ['📍', 'Place'] };
|
||||||
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
|
/** 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); };
|
const chShareOk = link => { if (/^https:\/\/maps\.apple\.com\/\?ll=/.test(String(link || ''))) return true; const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
|
||||||
function chShareCard(m) {
|
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 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>`}
|
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>`;
|
<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>`;
|
||||||
|
if (s.kind === 'place') return `<a class="sgshare" href="${esc(s.link)}" target="_blank" rel="noopener noreferrer">${body}<span class="sgshare-go">›</span></a>`;
|
||||||
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
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) {
|
||||||
@@ -162,7 +164,7 @@ function chWireComposer() {
|
|||||||
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
|
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
|
||||||
chMentionBox(ta);
|
chMentionBox(ta);
|
||||||
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
|
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
|
||||||
chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
|
chGifOn(); chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, ...(ch.gifOn ? [{ label: 'GIF', icon: 'play', run: chGifPicker }] : []), { label: 'Place or location', icon: 'pin', run: chPlacePicker }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
|
||||||
chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
|
chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
|
||||||
const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
|
const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
|
||||||
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
|
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
|
||||||
@@ -187,9 +189,10 @@ async function chPeople() { if (!ch.people) ch.people = await chApi('/people');
|
|||||||
async function chNewChat() {
|
async function chNewChat() {
|
||||||
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
|
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
|
||||||
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
|
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
|
||||||
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
|
<div class="bar" style="flex-wrap:wrap">${people.length > 1 ? '<button class="btn ghost" id="chg" style="flex:1;justify-content:center">New group…</button>' : ''}${typeof sgNewChat === 'function' && typeof sgInChat === 'function' && sgInChat() ? '<button class="btn ghost" id="chsgn" style="flex:1;justify-content:center">New Signal chat…</button>' : ''}</div>`);
|
||||||
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
|
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
|
||||||
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
|
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
|
||||||
|
const sn = el.querySelector('#chsgn'); if (sn) sn.onclick = () => { closeSheet(); sg.inChat = true; sg.root = ch.root; sgNewChat(); };
|
||||||
}
|
}
|
||||||
function chNewGroup(people) {
|
function chNewGroup(people) {
|
||||||
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
|
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
|
||||||
@@ -349,10 +352,11 @@ 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); }
|
||||||
const q = pr.quiet, admin = ME && ME.role === 'admin';
|
const q = pr.quiet, admin = ME && ME.role === 'admin';
|
||||||
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
|
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
|
||||||
|
${typeof allowed === 'function' && allowed('signal') ? `<a class="row" href="#/signal" id="chsig" style="background:var(--panel2);border-radius:12px;margin:10px 0"><span class="grow"><b>Signal</b><small style="white-space:normal">Link or unlink your Signal account, and start a new Signal conversation. Your Signal chats are in this list.</small></span><span style="color:var(--accent)">Open ›</span></a>` : ''}
|
||||||
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
|
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
|
||||||
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
|
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||||
el.querySelector('#chsx').onclick = closeSheet;
|
el.querySelector('#chsx').onclick = closeSheet; const sgl = el.querySelector('#chsig'); if (sgl) sgl.onclick = () => closeSheet();
|
||||||
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
|
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
|
||||||
}
|
}
|
||||||
/** Home: the family announcement, if there is one (each person can dismiss it). */
|
/** Home: the family announcement, if there is one (each person can dismiss it). */
|
||||||
|
|||||||
+13
-2
@@ -6,13 +6,23 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
|
|||||||
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
|
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
|
||||||
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
|
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
|
// On a phone the editor normally uses OnlyOffice's phone layout, which on some OnlyOffice editions only lets you read. "Full editor" asks for the desktop layout instead (scaled to fit the screen),
|
||||||
|
// and "Edit elsewhere" downloads a copy to edit in another app and uploads it back over this file.
|
||||||
|
const phone = innerWidth < 768, full = phone && store.get('oo_layout', 'auto') === 'full';
|
||||||
|
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b>${phone ? `<button class="btn ghost" id="edfull" title="${full ? 'Back to the phone layout' : 'Use the full desktop editor (small, but it can edit)'}">${full ? 'Phone view' : 'Full editor'}</button><button class="ibtn" id="edmore" aria-label="Edit elsewhere" title="Edit in another app">${icon('more')}</button>` : ''}</div>
|
||||||
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
|
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
|
||||||
|
if (phone) {
|
||||||
|
$('#edfull').onclick = () => { store.set('oo_layout', full ? 'auto' : 'full'); viewEdit([repo, ...rawParts], opts); };
|
||||||
|
$('#edmore').onclick = () => actionSheet(name, 'Edit in another app', [
|
||||||
|
{ label: 'Download a copy', icon: 'dl', run: () => downloadItem(repo, path) },
|
||||||
|
{ label: 'Replace with an edited copy…', icon: 'up', run: () => { const f = document.createElement('input'); f.type = 'file'; f.accept = '.' + name.split('.').pop(); f.onchange = async () => { const file = f.files[0]; if (!file) return; try { const dir = path.slice(0, path.lastIndexOf('/')) || '/', link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData(); fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', file, name); const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd }); if (!up.ok) throw new Error('Could not replace it (' + up.status + ')'); toast('Replaced with your edited copy'); viewEdit([repo, ...rawParts], opts); } catch (e) { toast(e.message); } }; f.click(); } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
if (!sf.token()) return (location.hash = '#/files');
|
if (!sf.token()) return (location.hash = '#/files');
|
||||||
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
|
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
|
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
|
||||||
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
|
body: JSON.stringify({ repo, path, mobile: phone && !full }) });
|
||||||
const conf = await r.json();
|
const conf = await r.json();
|
||||||
if (!r.ok) return fail(conf.error || 'Could not open document');
|
if (!r.ok) return fail(conf.error || 'Could not open document');
|
||||||
if (!window.DocsAPI) await new Promise((ok, no) => {
|
if (!window.DocsAPI) await new Promise((ok, no) => {
|
||||||
@@ -22,6 +32,7 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
|
|||||||
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
|
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
|
||||||
$('#oomsg').remove();
|
$('#oomsg').remove();
|
||||||
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
|
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
|
||||||
|
if (full) { const wrap = $('.edwrap'), W = 1000, k = Math.min(1, wrap.clientWidth / W), box = $('#oo'); wrap.style.overflow = 'hidden'; box.style.cssText = `width:${W}px;height:${Math.round(wrap.clientHeight / k)}px;transform:scale(${k});transform-origin:0 0`; } // (the desktop editor is laid out wide, then shrunk to fit the phone)
|
||||||
editor = new DocsAPI.DocEditor('oo', conf);
|
editor = new DocsAPI.DocEditor('oo', conf);
|
||||||
} catch (e) { fail(e.message); }
|
} catch (e) { fail(e.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<script defer src="/file-actions.js"></script>
|
<script defer src="/file-actions.js"></script>
|
||||||
<script defer src="/signal-view.js"></script>
|
<script defer src="/signal-view.js"></script>
|
||||||
<script defer src="/chat-view.js"></script>
|
<script defer src="/chat-view.js"></script>
|
||||||
|
<script defer src="/chat-extras.js"></script>
|
||||||
<script defer src="/notes-view.js"></script>
|
<script defer src="/notes-view.js"></script>
|
||||||
<script defer src="/chores-view.js"></script>
|
<script defer src="/chores-view.js"></script>
|
||||||
<script defer src="/family-view.js"></script>
|
<script defer src="/family-view.js"></script>
|
||||||
|
|||||||
+26
-11
@@ -21,7 +21,7 @@ function lyrParseLrc(text) {
|
|||||||
}
|
}
|
||||||
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
||||||
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
||||||
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
const lyr = { fs: false, open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
||||||
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
||||||
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
|
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
|
||||||
@@ -77,41 +77,49 @@ async function lyrLoad(t, force) {
|
|||||||
return lyrForRadio(t, res);
|
return lyrForRadio(t, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lyrOpen() {
|
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
|
||||||
const t = cur(); if (!t || lyr.open) return;
|
function lyrOpen(fs) {
|
||||||
lyr.open = true; document.body.classList.add('lyropen');
|
const t = cur(); if (!t) return;
|
||||||
|
if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; }
|
||||||
|
lyr.fs = fs === true; lyr.open = true; document.body.classList.add('lyropen');
|
||||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||||
}
|
}
|
||||||
function lyrClose() {
|
function lyrClose() {
|
||||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
||||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
|
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||||
}
|
}
|
||||||
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||||
|
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
|
||||||
function lyrShell() {
|
function lyrShell() {
|
||||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
||||||
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
|
lyr.el.className = lyrCls();
|
||||||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
||||||
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
||||||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||||||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||||||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||||||
|
<div class="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||||
|
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></div>
|
||||||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
||||||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</button></div>`;
|
||||||
const $l = q => lyr.el.querySelector(q);
|
const $l = q => lyr.el.querySelector(q);
|
||||||
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
||||||
|
const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; };
|
||||||
|
$l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
|
if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {});
|
||||||
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
||||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
||||||
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
||||||
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
||||||
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
||||||
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
||||||
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
||||||
syncUI();
|
modeUI(); syncUI();
|
||||||
}
|
}
|
||||||
async function lyrReload(force) {
|
async function lyrReload(force) {
|
||||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
||||||
@@ -129,14 +137,21 @@ function lyrDraw() {
|
|||||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
||||||
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
||||||
}
|
}
|
||||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
|
||||||
}
|
}
|
||||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; lyrQueue(body); return; }
|
||||||
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
||||||
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
||||||
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
||||||
body.scrollTop = 0; lyr.idx = -2;
|
body.scrollTop = 0; lyr.idx = -2;
|
||||||
}
|
}
|
||||||
|
/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */
|
||||||
|
function lyrQueue(body) {
|
||||||
|
if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return;
|
||||||
|
const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40);
|
||||||
|
body.insertAdjacentHTML('beforeend', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
|
||||||
|
body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
|
||||||
|
}
|
||||||
function lyrSearchAgain() {
|
function lyrSearchAgain() {
|
||||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
||||||
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||||||
|
|||||||
+10
-1
@@ -173,7 +173,7 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
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>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
<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 +182,15 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
|
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
|
||||||
if (mine) {
|
if (mine) {
|
||||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||||
|
$('#pmv').onclick = () => tracks.length ? textSheet('Move to NE Hub', 'Name of the Family playlist', 'Move', async n => {
|
||||||
|
try {
|
||||||
|
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key)); if (!meta.length) return toast('No songs here can be moved');
|
||||||
|
const r = await mmApi('/family', mmOpt('POST', { name: n, tracks: meta.slice(0, 500) }));
|
||||||
|
for (let i = 500; i < meta.length; i += 500) await mmApi(`/family/${r.id}/add`, mmOpt('POST', { tracks: meta.slice(i, i + 500) }));
|
||||||
|
toast('Moved ' + meta.length + ' songs to NE Hub');
|
||||||
|
confirmSheet('Remove it from Plex too?', '“' + title + '” is now a Family playlist. Cancel keeps the Plex one.', 'Remove from Plex', async () => { try { await plx.del(key); } catch (e) { toast(e.message); } location.hash = '#/music/family/' + r.id; });
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
}, title) : toast('This playlist has no songs');
|
||||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
$('#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 });
|
||||||
|
|||||||
@@ -20,16 +20,26 @@ function ofOpen(repo, path, name) {
|
|||||||
}
|
}
|
||||||
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
|
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
|
||||||
|
|
||||||
|
/** Ready-made starting points (files in public/templates): the person gets their own copy to fill in. */
|
||||||
|
const OFFICE_TEMPLATES = [
|
||||||
|
['letter.docx', 'Letter', 'A formal or friendly letter, laid out', 'docx'], ['resume.docx', 'Resume', 'Summary, experience, education and skills', 'docx'], ['meeting-notes.docx', 'Meeting notes', 'Agenda, notes and action items', 'docx'],
|
||||||
|
['family-newsletter.docx', 'Family newsletter', 'News, who did what and what is coming up', 'docx'], ['recipe-card.docx', 'Recipe card', 'Ingredients, steps and notes', 'docx'],
|
||||||
|
['monthly-budget.xlsx', 'Monthly budget', 'Planned against actual, with the math done', 'xlsx'], ['expense-tracker.xlsx', 'Expense tracker', 'One line per purchase, totals by category', 'xlsx'],
|
||||||
|
['chore-chart.xlsx', 'Chore chart', 'A week of chores with a count for each', 'xlsx'], ['meal-planner.xlsx', 'Weekly meal planner', 'Breakfast, lunch, dinner and a shopping list', 'xlsx'],
|
||||||
|
['simple-presentation.pptx', 'Simple presentation', 'Title, agenda, key points and a thank-you', 'pptx'],
|
||||||
|
];
|
||||||
function viewOffice(args = []) {
|
function viewOffice(args = []) {
|
||||||
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
||||||
const signed = !!sf.token();
|
const signed = !!sf.token();
|
||||||
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
|
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
|
||||||
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
|
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
|
||||||
|
<details class="otplbox" id="otpl"><summary><b>Start from a template</b> <small>${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides</small></summary><div class="otpl">${OFFICE_TEMPLATES.map((t, i) => `<button type="button" class="octile otc" data-t="${i}" style="--c:${OFFICE_KINDS[ofKind('x.' + t[3])].color}">${ofIcon('x.' + t[3])}<span><b>${esc(t[1])}</b><small>${esc(t[2])}</small></span></button>`).join('')}</div></details>
|
||||||
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
|
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
|
||||||
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
||||||
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
|
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
|
||||||
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
|
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
|
||||||
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
|
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
|
||||||
|
$('#view').querySelectorAll('.otc').forEach(b => b.onclick = () => { const t = OFFICE_TEMPLATES[+b.dataset.t]; newOfficeDoc(t[3], t[1], t[0], t[1]); });
|
||||||
if (!signed) return;
|
if (!signed) return;
|
||||||
$('#ofup').onclick = () => $('#ofupf').click();
|
$('#ofup').onclick = () => $('#ofupf').click();
|
||||||
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
|
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
|
||||||
|
|||||||
+34
-19
@@ -51,6 +51,7 @@ async function tcPump() {
|
|||||||
}
|
}
|
||||||
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
||||||
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
||||||
|
addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && cur() && !pl.a.paused && (pl.a.readyState < 3 || pl.a.error)) { pl.retries = 0; recover(true); } }); // (back from sleep or another app)
|
||||||
|
|
||||||
let loadTok = 0, loadTimer = null, stallTimer = null;
|
let loadTok = 0, loadTimer = null, stallTimer = null;
|
||||||
pl.retries = 0; pl.buf = false;
|
pl.retries = 0; pl.buf = false;
|
||||||
@@ -60,10 +61,12 @@ function loadCur(delay = 220) {
|
|||||||
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
|
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
|
||||||
const tok = ++loadTok; pl.buf = true;
|
const tok = ++loadTok; pl.buf = true;
|
||||||
if (delay) pl.a.pause();
|
if (delay) pl.a.pause();
|
||||||
|
try { // (a bug in any screen's refresh must never stop the music from starting)
|
||||||
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||||
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
||||||
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
||||||
|
} catch (e) { console.error('player refresh', e); }
|
||||||
const go = () => {
|
const go = () => {
|
||||||
if (tok !== loadTok) return;
|
if (tok !== loadTok) return;
|
||||||
const hit = tc.m.get(t.src);
|
const hit = tc.m.get(t.src);
|
||||||
@@ -84,7 +87,8 @@ function recover(now) {
|
|||||||
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
||||||
if (!t || a.paused) return;
|
if (!t || a.paused) return;
|
||||||
stallTimer = setTimeout(() => {
|
stallTimer = setTimeout(() => {
|
||||||
if (a.paused || (a.readyState >= 3 && !a.error)) return;
|
const forced = pl.forceRecover; pl.forceRecover = false;
|
||||||
|
if (a.paused || (!forced && a.readyState >= 3 && !a.error)) return;
|
||||||
const hit = tc.m.get(t.src), pos = a.currentTime;
|
const hit = tc.m.get(t.src), pos = a.currentTime;
|
||||||
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
||||||
if (bad && !hit) {
|
if (bad && !hit) {
|
||||||
@@ -93,7 +97,9 @@ function recover(now) {
|
|||||||
}
|
}
|
||||||
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||||
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
||||||
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
|
if (!hit && !bad && pl.retries >= 5) { // keep trying every 15 s (the 'online' signal is not always sent, e.g. when only the server was unreachable)
|
||||||
|
if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); }
|
||||||
|
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
|
||||||
if (!hit || a.src !== hit.url) pl.retries++;
|
if (!hit || a.src !== hit.url) pl.retries++;
|
||||||
a.src = want;
|
a.src = want;
|
||||||
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
||||||
@@ -162,7 +168,10 @@ function tlReport(state) {
|
|||||||
tlSend(state, tlKey, tlPos, tlDur);
|
tlSend(state, tlKey, tlPos, tlDur);
|
||||||
}
|
}
|
||||||
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
||||||
pl.a.addEventListener('ended', () => advance(true));
|
pl.a.addEventListener('ended', () => { // a dropped connection can look like the end of the song: if it stopped well short of the length, carry on from there instead of skipping
|
||||||
|
const a = pl.a, t = cur(), d = t && t.dur ? t.dur / 1000 : 0;
|
||||||
|
if (t && d > 20 && !/^radio:/.test(String(t.key)) && a.currentTime < d - 8 && pl.endRetry !== t.key + '|' + Math.floor(a.currentTime / 5)) { pl.endRetry = t.key + '|' + Math.floor(a.currentTime / 5); pl.forceRecover = true; pl.buf = true; syncUI(); recover(true); return; }
|
||||||
|
advance(true); });
|
||||||
let lastSave = 0, lastPos = 0;
|
let lastSave = 0, lastPos = 0;
|
||||||
pl.a.addEventListener('timeupdate', () => {
|
pl.a.addEventListener('timeupdate', () => {
|
||||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
||||||
@@ -174,7 +183,7 @@ pl.a.addEventListener('timeupdate', () => {
|
|||||||
});
|
});
|
||||||
addEventListener('pagehide', saveState);
|
addEventListener('pagehide', saveState);
|
||||||
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
||||||
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
|
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
|
||||||
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
||||||
pl.a.addEventListener('canplaythrough', () => tcKick());
|
pl.a.addEventListener('canplaythrough', () => tcKick());
|
||||||
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
||||||
@@ -195,12 +204,12 @@ function renderMini() {
|
|||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||||
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
||||||
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
|
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = () => vizOpen(); $('#mq').onclick = openQueue; $('#mz').onclick = () => sleepSheet();
|
||||||
// tap or drag along the thin bar to seek
|
// tap or drag along the thin bar to seek
|
||||||
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
||||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||||
@@ -221,8 +230,8 @@ function stopPlayer() {
|
|||||||
}
|
}
|
||||||
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||||
function modeUI() {
|
function modeUI() {
|
||||||
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
||||||
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
document.querySelectorAll('#mr, #npr, #cnr, #fsr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||||
}
|
}
|
||||||
function syncUI() {
|
function syncUI() {
|
||||||
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||||
@@ -246,15 +255,18 @@ function syncUI() {
|
|||||||
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
||||||
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
||||||
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
||||||
|
const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT;
|
||||||
|
const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
|
||||||
|
const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000);
|
||||||
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
||||||
}
|
}
|
||||||
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
|
// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls).
|
||||||
function renderNpCard() {
|
function renderNpCard() {
|
||||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
|
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
|
||||||
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
|
const host = onHome ? $('#npslot') : null;
|
||||||
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
||||||
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
||||||
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
|
if (!host.contains(el)) host.appendChild(el);
|
||||||
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
||||||
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||||
@@ -273,22 +285,25 @@ function renderNpCard() {
|
|||||||
function updateNow() {
|
function updateNow() {
|
||||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
||||||
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
|
const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1);
|
||||||
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||||
modeUI();
|
modeUI();
|
||||||
}
|
}
|
||||||
function openNow() {
|
function openNow() {
|
||||||
const t = cur(); if (!t) return;
|
const t = cur(); if (!t) return;
|
||||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
|
const chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
|
||||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
|
||||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></div>
|
||||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
<div class="npseek"><input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div></div>
|
||||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
|
||||||
|
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}</div></div>`);
|
||||||
|
el.querySelector('.sheet').classList.add('npsheet');
|
||||||
const sk = el.querySelector('#npk');
|
const sk = el.querySelector('#npk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||||
|
el.querySelector('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); };
|
||||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||||
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
||||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
|
|||||||
+12
-6
@@ -7,12 +7,14 @@ const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateSt
|
|||||||
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
||||||
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
||||||
const sgOnView = () => location.hash.startsWith('#/signal');
|
const sgOnView = () => location.hash.startsWith('#/signal');
|
||||||
|
/** Where a Signal conversation opens: inside Chat when the person has both (the Chat list shows Signal chats too), otherwise the Signal screen. */
|
||||||
|
const sgHref = id => typeof sgInChat === 'function' && sgInChat() ? '#/chat/' + encodeURIComponent('sg:' + id) : '#/signal/' + encodeURIComponent(id);
|
||||||
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
||||||
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
||||||
if (!allowed('signal') || sg.es) return;
|
if (!allowed('signal') || sg.es) return;
|
||||||
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
|
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
|
||||||
sg.es = new EventSource('/api/signal/events');
|
sg.es = new EventSource('/api/signal/events');
|
||||||
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
|
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); if (typeof chSigChanged === 'function') chSigChanged(); };
|
||||||
}
|
}
|
||||||
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
|
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
|
||||||
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
|
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
|
||||||
@@ -38,7 +40,7 @@ function sgAlert(ev) { // a message from someone else arrived and you are not lo
|
|||||||
if (a.sound) sgChime();
|
if (a.sound) sgChime();
|
||||||
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
|
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
|
||||||
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
|
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
|
||||||
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView() || sg.inChat) location.hash = sgHref(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sgEvT = null;
|
let sgEvT = null;
|
||||||
@@ -49,6 +51,9 @@ function sgEvent(ev) {
|
|||||||
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
|
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
|
||||||
}
|
}
|
||||||
if (ev.t === 'gone') sgGone(ev.conv, true);
|
if (ev.t === 'gone') sgGone(ev.conv, true);
|
||||||
|
if (sg.inChat) { // a Signal conversation is open inside Chat: refresh it and the combined list, never turn the Chat screen into the Signal screen
|
||||||
|
if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (sg.cur && ev.t !== 'status') sgDrawThread(false); if (typeof chSigChanged === 'function') chSigChanged(); }, 150); return;
|
||||||
|
}
|
||||||
if (!sgMounted()) return;
|
if (!sgMounted()) return;
|
||||||
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
||||||
}
|
}
|
||||||
@@ -57,7 +62,8 @@ function sgEvent(ev) {
|
|||||||
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
||||||
// ... and the same screens in a panel that floats over every other screen.
|
// ... and the same screens in a panel that floats over every other screen.
|
||||||
async function sgMount(root, dock) {
|
async function sgMount(root, dock) {
|
||||||
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
|
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
|
||||||
|
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
|
||||||
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
||||||
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (sg.root !== root) return; sg.st = st;
|
if (sg.root !== root) return; sg.st = st;
|
||||||
@@ -114,7 +120,7 @@ async function sgDrawThread(first) {
|
|||||||
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
||||||
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
||||||
if (!box.querySelector('.sgbar')) {
|
if (!box.querySelector('.sgbar')) {
|
||||||
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="${sg.inChat ? '#/chat' : '#/signal'}" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
||||||
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
||||||
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
||||||
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
||||||
@@ -186,7 +192,7 @@ function sgChatMenu(id) {
|
|||||||
}
|
}
|
||||||
function sgGone(id, fromServer) {
|
function sgGone(id, fromServer) {
|
||||||
sg.convs = sg.convs.filter(x => x.id !== id);
|
sg.convs = sg.convs.filter(x => x.id !== id);
|
||||||
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
|
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sg.inChat) { location.hash = '#/chat'; } else if (sgOnView()) location.hash = '#/signal'; }
|
||||||
if (!fromServer || sgMounted()) sgRefresh();
|
if (!fromServer || sgMounted()) sgRefresh();
|
||||||
}
|
}
|
||||||
function sgEditContact(c, after) {
|
function sgEditContact(c, after) {
|
||||||
@@ -205,7 +211,7 @@ async function sgNewChat() {
|
|||||||
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
|
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
|
||||||
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
|
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
|
||||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
|
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
|
||||||
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = sgHref(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
||||||
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
||||||
}
|
}
|
||||||
function sgLinkScreen(root, dock) {
|
function sgLinkScreen(root, dock) {
|
||||||
|
|||||||
+53
-4
@@ -659,8 +659,9 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
||||||
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
||||||
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
||||||
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
|
.vstage.vfull { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; }
|
||||||
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
|
body.vfullwin #player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 91; }
|
||||||
|
.vstage.vfull video, .vstage:fullscreen video { height: calc(100dvh - var(--playerh, 0px)); max-height: none; object-fit: contain; }
|
||||||
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
||||||
.vstage:fullscreen video { height: 100%; }
|
.vstage:fullscreen video { height: 100%; }
|
||||||
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
||||||
@@ -689,7 +690,7 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
||||||
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
||||||
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||||
.octile.rc { padding: 10px 12px; }
|
.octile.rc { padding: 10px 12px; } .octile .ic.ofic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; flex: none; } .octile .ic.ofic svg { width: 20px; height: 20px; margin-top: -5px; } .octile .ic.ofic em { bottom: 3px; }
|
||||||
.orow .rmain { min-width: 0; }
|
.orow .rmain { min-width: 0; }
|
||||||
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
||||||
|
|
||||||
@@ -705,7 +706,6 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vmb .vskip em { font-size: 8px; top: 6px; }
|
.vmb .vskip em { font-size: 8px; top: 6px; }
|
||||||
|
|
||||||
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
||||||
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
|
|
||||||
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
||||||
.vtop .vtc { flex: 0 1 auto; }
|
.vtop .vtc { flex: 0 1 auto; }
|
||||||
@@ -1107,3 +1107,52 @@ html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !impo
|
|||||||
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
|
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
|
||||||
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
|
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
|
||||||
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
||||||
|
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
|
||||||
|
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
|
||||||
|
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
|
||||||
|
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
|
||||||
|
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
|
||||||
|
|
||||||
|
/* Now Playing sheet: cover, title, seek, controls, and a row of extras */
|
||||||
|
#sheet .sheet.npsheet h3:empty { display: none; }
|
||||||
|
#sheet .sheet.npsheet { padding-top: 14px; }
|
||||||
|
.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); }
|
||||||
|
.np .npmeta { padding: 0 8px; }
|
||||||
|
.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.np .npseek { margin: 16px 0 0; }
|
||||||
|
.np .ctl { gap: 8px; margin: 6px 0 14px; }
|
||||||
|
.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; }
|
||||||
|
.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||||
|
.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; }
|
||||||
|
.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; }
|
||||||
|
.npchip.on { color: var(--danger); border-color: currentColor; }
|
||||||
|
/* full screen: art and words side by side, the player along the bottom */
|
||||||
|
#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; }
|
||||||
|
#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; }
|
||||||
|
#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; }
|
||||||
|
#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; }
|
||||||
|
#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; }
|
||||||
|
#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; }
|
||||||
|
#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; }
|
||||||
|
#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||||
|
#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); }
|
||||||
|
#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; }
|
||||||
|
#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; }
|
||||||
|
@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } }
|
||||||
|
|
||||||
|
/* no lyrics: the queue fills the space */
|
||||||
|
.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; }
|
||||||
|
.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); }
|
||||||
|
.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); }
|
||||||
|
.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; }
|
||||||
|
#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }
|
||||||
|
|
||||||
|
.mini .vbvol { width: 84px; accent-color: var(--accent); } .mini .ibtn.txt { width: auto; min-width: 40px; font-size: 13px; font-weight: 600; padding: 0 6px; }
|
||||||
|
|
||||||
|
/* full screen: the bottom bar slides away with the other controls after a few idle seconds */
|
||||||
|
body.vfullwin #player { transition: transform .25s ease, opacity .25s; }
|
||||||
|
body.vfullwin:has(.vstage.vhide) #player { transform: translateY(100%); opacity: 0; pointer-events: none; }
|
||||||
|
body.vfullwin:has(.vstage.vhide) .vstage.vfull { bottom: 0; } body.vfullwin:has(.vstage.vhide) .vstage.vfull video { height: 100dvh; }
|
||||||
+1
-1
@@ -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-v127';
|
const CACHE = 'hub-v134';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const 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'];
|
||||||
|
|||||||
+7
-7
@@ -193,26 +193,26 @@ function chooseFolder(title, start, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
|
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
|
||||||
async function createFromTemplate(repo, dir, name, x) {
|
async function createFromTemplate(repo, dir, name, x, tpl) {
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
|
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
|
||||||
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
|
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
|
const tplRes = await fetch('/templates/' + (tpl || 'blank.' + x)); if (!tplRes.ok) throw new Error('Template missing');
|
||||||
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
|
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
|
||||||
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
|
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tplRes.blob(), name);
|
||||||
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
|
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
|
||||||
if (!r.ok) throw new Error('Could not create ' + name);
|
if (!r.ok) throw new Error('Could not create ' + name);
|
||||||
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
|
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newOfficeDoc(x, label) {
|
async function newOfficeDoc(x, label, tpl, defName) {
|
||||||
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
|
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
|
||||||
let loc;
|
let loc;
|
||||||
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
|
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
|
const el = openSheet(tpl ? 'New from template' : 'New ' + label, tpl ? label : '', `<form><label for="nn">File name</label><input type="text" id="nn" value="${esc(defName || 'Untitled')}" autocomplete="off" autocapitalize="off" required>
|
||||||
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
|
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
|
||||||
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
|
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
|
||||||
@@ -221,7 +221,7 @@ async function newOfficeDoc(x, label) {
|
|||||||
el.querySelector('#nl').onclick = async () => {
|
el.querySelector('#nl').onclick = async () => {
|
||||||
// Sheets don't stack, so close this one, choose, then reopen with the new location.
|
// Sheets don't stack, so close this one, choose, then reopen with the new location.
|
||||||
const picked = await chooseFolder('Save in…', loc);
|
const picked = await chooseFolder('Save in…', loc);
|
||||||
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
|
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label, tpl, defName);
|
||||||
};
|
};
|
||||||
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
|
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
|
||||||
el.querySelector('form').onsubmit = async e => {
|
el.querySelector('form').onsubmit = async e => {
|
||||||
@@ -230,7 +230,7 @@ async function newOfficeDoc(x, label) {
|
|||||||
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
|
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
|
||||||
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
|
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
|
const name = await createFromTemplate(loc.repo, loc.dir, want, x, tpl), path = joinP(loc.dir, name);
|
||||||
addRecent(loc.repo, path, name); closeSheet();
|
addRecent(loc.repo, path, name); closeSheet();
|
||||||
if (cfg.editor) location.hash = officeHash(loc.repo, path);
|
if (cfg.editor) location.hash = officeHash(loc.repo, path);
|
||||||
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
|
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26
-32
@@ -310,34 +310,18 @@ function videoMount() {
|
|||||||
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
||||||
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
||||||
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
||||||
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
|
</div>
|
||||||
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
||||||
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
|
||||||
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
|
|
||||||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||||
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
||||||
$('#vvol').value = Math.round(el.volume * 100);
|
|
||||||
const total = () => (vp.m.duration || 0) / 1000;
|
|
||||||
let drag = false, hideT = null;
|
let drag = false, hideT = null;
|
||||||
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
|
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => { if (document.querySelector('#player:hover, #player:focus-within')) wake(); else stage.classList.add('vhide'); }, 3000); }; // (in full screen the bottom bar hides with the rest, but not while you are on it)
|
||||||
vp.wake = wake; vp.drag = () => drag;
|
vp.wake = wake; vp.drag = () => drag;
|
||||||
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
||||||
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
|
||||||
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
|
||||||
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
|
||||||
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
|
|
||||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
|
||||||
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
|
||||||
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
|
||||||
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
|
|
||||||
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
|
|
||||||
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
|
|
||||||
const range = $('#vrange');
|
|
||||||
range.oninput = () => { drag = true; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
|
|
||||||
let lastTap = 0, lastDbl = 0;
|
let lastTap = 0, lastDbl = 0;
|
||||||
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||||
stage.addEventListener('pointerup', e => {
|
stage.addEventListener('pointerup', e => {
|
||||||
if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
|
if (e.target.closest('.vtop, #vnextbox, .vmark')) return;
|
||||||
if (e.pointerType === 'touch') {
|
if (e.pointerType === 'touch') {
|
||||||
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
||||||
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
|
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
|
||||||
@@ -414,11 +398,21 @@ function videoBar() {
|
|||||||
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
||||||
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
||||||
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
||||||
|
<button class="ibtn wide-only" id="vmute" aria-label="Mute" title="Mute">${icon(vp.el.muted || vp.el.volume === 0 ? 'mute' : 'vol')}</button><input class="wide-only vbvol" type="range" id="vvol" min="0" max="100" value="${Math.round(vp.el.volume * 100)}" aria-label="Volume">
|
||||||
|
<button class="ibtn wide-only txt" id="vspd" aria-label="Playback speed" title="Playback speed">${vp.speed}×</button>${castOk(vp.el) ? `<button class="ibtn wide-only" id="vcast" aria-label="Play on a TV or speaker" title="Play on a TV or speaker">${icon('ext')}</button>` : ''}
|
||||||
|
<button class="ibtn" id="vset" aria-label="Quality and tracks" title="Quality and tracks">${icon('cog')}</button>${document.pictureInPictureEnabled ? `<button class="ibtn wide-only" id="vpip" aria-label="Picture in picture" title="Picture in picture">${icon('pip')}</button>` : ''}<button class="ibtn" id="vfs" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
||||||
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
||||||
const q = s => el.querySelector(s);
|
const q = s => el.querySelector(s);
|
||||||
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
||||||
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
||||||
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
||||||
|
const el0 = vp.el;
|
||||||
|
q('#vmute').onclick = () => { el0.muted = !el0.muted; };
|
||||||
|
q('#vvol').oninput = e => { el0.volume = e.target.value / 100; el0.muted = el0.volume === 0; store.set('video_vol', el0.volume); };
|
||||||
|
q('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el0.playbackRate = r; q('#vspd').textContent = r + '×'; } })));
|
||||||
|
if (q('#vcast')) q('#vcast').onclick = () => castTo(vp && vp.el);
|
||||||
|
q('#vset').onclick = videoSettings; q('#vfs').onclick = videoFullscreen;
|
||||||
|
if (q('#vpip')) q('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el0.requestPictureInPicture(); } catch {} };
|
||||||
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||||
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
||||||
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
|
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
|
||||||
@@ -467,19 +461,19 @@ function videoGo(dir) {
|
|||||||
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
||||||
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||||
function videoFullscreen() {
|
function videoFullscreen() {
|
||||||
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
|
if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return;
|
||||||
const native = document.fullscreenElement || document.webkitFullscreenElement;
|
if (box.classList.contains('vfull')) return videoFsExit();
|
||||||
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
|
box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls)
|
||||||
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
|
const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen;
|
||||||
const req = box.requestFullscreen || box.webkitRequestFullscreen;
|
if (req) { try { const p = req.call(root); if (p && p.catch) p.catch(() => {}); } catch {} } // (if the browser refuses, the full-window mode above is what you get)
|
||||||
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
|
|
||||||
if (!req) return fallback();
|
|
||||||
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
|
|
||||||
const p = req.call(box); if (p && p.catch) p.catch(fallback);
|
|
||||||
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
|
|
||||||
} catch { fallback(); }
|
|
||||||
}
|
}
|
||||||
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
|
function videoFsExit() {
|
||||||
|
const box = document.querySelector('.vstage.vfull'); if (box) box.classList.remove('vfull'); document.body.classList.remove('vfullwin');
|
||||||
|
if (document.fullscreenElement || document.webkitFullscreenElement) { try { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } catch {} }
|
||||||
|
}
|
||||||
|
// in full screen, touching or moving over the picture or the bar brings the hidden bar back
|
||||||
|
['pointermove', 'pointerdown', 'keydown'].forEach(ev => addEventListener(ev, () => { if (vp && vp.wake && document.body.classList.contains('vfullwin')) vp.wake(); }, true));
|
||||||
|
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('vfullwin')) videoFsExit(); });
|
||||||
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
|
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
|
||||||
function videoRescue(why, at) {
|
function videoRescue(why, at) {
|
||||||
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
|
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
|
||||||
@@ -568,7 +562,7 @@ addEventListener('keydown', e => {
|
|||||||
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
||||||
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
|
else if (k === 'Escape') videoFsExit();
|
||||||
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
||||||
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||||
}, true);
|
}, true);
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ function securityHeaders(res) {
|
|||||||
res.setHeader('Referrer-Policy', 'same-origin');
|
res.setHeader('Referrer-Policy', 'same-origin');
|
||||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
|
||||||
res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked)
|
res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked)
|
||||||
res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(), payment=()');
|
res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(self), payment=()');
|
||||||
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
|
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
|
||||||
}
|
}
|
||||||
/** Cross-site request check: a browser request that changes something must come from this same site. */
|
/** Cross-site request check: a browser request that changes something must come from this same site. */
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Family Chat extras: GIFs (kept family-friendly, fetched through the hub, kept in the chat) and sharing a place (a search, and coordinates that are checked).
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chatx-'));
|
||||||
|
const GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); let commonsPages = null, asked = [], tenorQ = [], giphyQ = [], wikiQ = [];
|
||||||
|
const media = http.createServer((req, res) => { asked.push(req.url); if (req.url.startsWith('/big')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.alloc(9 * 1024 * 1024)); } if (req.url.startsWith('/html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<b>x</b>'); } res.writeHead(200, { 'content-type': 'image/gif' }); res.end(GIF); }).listen(0);
|
||||||
|
const svc = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), M = 'http://127.0.0.1:' + media.address().port; res.setHeader('content-type', 'application/json');
|
||||||
|
if (u.pathname.startsWith('/v2/')) { tenorQ.push(u.pathname + u.search); return res.end(JSON.stringify({ next: 'n2', results: [
|
||||||
|
{ content_description: 'happy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t1.gif', dims: [100, 80] }, gif: { url: M + '/g1.gif', dims: [300, 240] } } },
|
||||||
|
{ content_description: 'sexy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t2.gif', dims: [100, 80] }, gif: { url: M + '/g2.gif', dims: [300, 240] } } },
|
||||||
|
{ content_description: 'party', tags: ['topless', 'fun'], media_formats: { tinygif: { url: M + '/t3.gif', dims: [100, 80] }, gif: { url: M + '/g3.gif', dims: [300, 240] } } },
|
||||||
|
{ content_description: 'elsewhere', tags: [], media_formats: { tinygif: { url: 'https://evil.example/t4.gif', dims: [1, 1] }, gif: { url: 'https://evil.example/g4.gif', dims: [1, 1] } } }] })); }
|
||||||
|
if (u.pathname.startsWith('/v1/gifs/')) { giphyQ.push(u.pathname + u.search); return res.end(JSON.stringify({ data: [{ title: 'cat', rating: 'g', images: { fixed_width_small: { url: M + '/s1.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f1.gif' } } }, { title: 'cat 2', rating: 'pg-13', images: { fixed_width_small: { url: M + '/s2.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f2.gif' } } }] })); }
|
||||||
|
if (u.pathname === '/w/api.php') { wikiQ.push(u.search); const M2 = M, mk = (i, title, mime, desc, cats, thumb) => ({ pageid: i, index: i, title: 'File:' + title, imageinfo: [{ mime, thumburl: thumb === null ? '' : (thumb || M2 + '/commons-' + i + '.gif'), thumbwidth: 320, thumbheight: 240, extmetadata: { ImageDescription: { value: desc }, Categories: { value: cats } } }] });
|
||||||
|
const pages = [mk(1, 'Cat walking.gif', 'image/gif', 'A <b>cat</b> walking', 'Cats|Animation'), mk(2, 'Nude study.gif', 'image/gif', 'Study', 'Nudity in art'), mk(3, 'Cat.png', 'image/png', 'still', 'Cats'), mk(4, 'Dancing robot.gif', 'image/gif', 'A robot', 'Robots'), mk(5, 'Clinic.gif', 'image/gif', 'Medical animation of the female genitalia', 'Medicine'), mk(6, 'No thumb.gif', 'image/gif', 'x', 'Cats', null)];
|
||||||
|
commonsPages = pages; const cont = pages.length ? { continue: { gsroffset: 24 } } : {}; return res.end(JSON.stringify({ ...cont, query: { pages: Object.fromEntries(pages.map(p => [p.pageid, p])) } })); }
|
||||||
|
if (u.pathname === '/search') { return res.end(JSON.stringify([{ name: 'Central Park', display_name: 'Central Park, Manhattan, New York, United States', lat: '40.78', lon: '-73.96' }, { name: 'Bad', display_name: 'Bad', lat: 'x', lon: 'y' }])); }
|
||||||
|
res.statusCode = 404; res.end('{}'); }).listen(0, async () => {
|
||||||
|
process.env.GIF_MEDIA_HOSTS = '127.0.0.1:' + media.address().port; process.env.WIKIMEDIA_URL = process.env.TENOR_URL = process.env.GIPHY_URL = process.env.NOMINATIM_URL = 'http://127.0.0.1:' + svc.address().port;
|
||||||
|
const users = require('../users'), chat = require('../chat'), gifs = require('../gifs');
|
||||||
|
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yw' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chat.handle(req, res))).listen(0, async () => {
|
||||||
|
const B = 'http://127.0.0.1:' + hub.address().port + '/api/chat', j = r => r.json(), call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who);
|
||||||
|
try {
|
||||||
|
// the filter's words
|
||||||
|
for (const bad of ['sexy dance', 'Boobs', 'naked man', 'p0rn'.replace('0', 'o'), 'bikini beach', 'F-U-C-K'.replace(/-/g, '')]) assert.equal(gifs.unsafe(bad), true, bad + ' is refused'); for (const ok of ['happy dance', 'class', 'assistant', 'Scunthorpe', 'cat', 'thanks', 'high five', 'book club', 'pass the butter']) assert.equal(gifs.unsafe(ok), false, ok + ' is fine');
|
||||||
|
// not set up
|
||||||
|
process.env.GIF_PROVIDER = 'off'; let r = await call('/gif/status'); assert.deepEqual(await j(r), { enabled: false, provider: '' }); assert.equal((await call('/gif?q=cat')).status, 503); delete process.env.GIF_PROVIDER;
|
||||||
|
// Tenor: always the strictest setting, and what comes back is filtered by words and by where it is hosted
|
||||||
|
process.env.TENOR_API_KEY = 'k1'; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'tenor' });
|
||||||
|
r = await j(await call('/gif?q=dance')); assert.match(tenorQ[0], /contentfilter=high/); assert.match(tenorQ[0], /q=dance/); assert.deepEqual(r.items.map(x => x.title), ['happy dance'], 'a title or a tag with a bad word, and a picture from another site, are dropped'); assert.equal(r.next, 'n2');
|
||||||
|
assert.equal((await call('/gif?q=sexy')).status, 400, 'a bad search is refused before it is sent'); assert.equal(tenorQ.length, 1, 'and never reaches the service'); await call('/gif'); assert.match(tenorQ[1], /featured/, 'with no words: what is popular');
|
||||||
|
// previews come through the hub (only ones it listed), never the phone's own request
|
||||||
|
const prev = r.items[0].preview; assert.equal((await call('/gifimg?u=' + encodeURIComponent(prev))).status, 200); assert.equal((await call('/gifimg?u=' + encodeURIComponent('http://127.0.0.1:' + media.address().port + '/other.gif'))).status, 400, 'an address the hub did not list is refused'); assert.equal((await call('/gifimg?u=' + encodeURIComponent('https://evil.example/x.gif'))).status, 400);
|
||||||
|
// sending: downloaded and kept in the chat like a photo
|
||||||
|
const dm = (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv; r = await post('/gif/send', { conv: dm.id, url: r.items[0].url, title: 'happy dance' }); assert.equal(r.status, 200); const m = (await j(r)).msg; assert.equal(m.att[0].type, 'image/gif'); assert.equal(m.att[0].name, 'happy dance.gif');
|
||||||
|
const f = await call('/file/' + m.att[0].id, {}, 'b'); assert.equal(f.status, 200); assert.deepEqual(Buffer.from(await f.arrayBuffer()), GIF, 'Bob sees the very same picture, from the hub'); assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/g1.gif' }, 'c')).status, 404, 'a stranger cannot send into the chat');
|
||||||
|
assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/never-listed.gif' })).status, 400, 'only listed GIFs can be sent');
|
||||||
|
// size and type limits
|
||||||
|
{ const M = 'http://127.0.0.1:' + media.address().port; gifs.remember([M + '/big.gif', M + '/html.gif']); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/big.gif' })).status, 413, 'a huge GIF is refused'); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/html.gif' })).status, 502, 'something that is not a picture is refused'); }
|
||||||
|
// Wikimedia Commons (MediaWiki's search): no key needed, the default when there is no Tenor or GIPHY key; animated GIFs only, and files with risky titles, descriptions or categories are dropped
|
||||||
|
delete process.env.TENOR_API_KEY; delete process.env.GIPHY_API_KEY; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'wikimedia' }, 'with no key, Commons is used');
|
||||||
|
r = await j(await call('/gif?q=cats')); assert.match(wikiQ[0], /filemime%3Aimage%2Fgif/); assert.match(wikiQ[0], /gsrsearch=cats/); assert.deepEqual(r.items.map(x => x.title), ['Cat walking', 'Dancing robot'], 'a risky category, a risky description, a still picture and one with no thumbnail are dropped, in search order'); assert.equal(r.next, '24'); assert.ok(!r.items.some(x => /Bikini|Nude/i.test(x.title)));
|
||||||
|
await call('/gif'); assert.match(wikiQ[1], /gsrsearch=animal/, 'with no words, something to look at'); r = await j(await call('/gif?q=cats&pos=24')); assert.match(wikiQ[2], /gsroffset=24/); assert.equal((await call('/gif?q=naked')).status, 400, 'the same word list applies');
|
||||||
|
{ const M = 'http://127.0.0.1:' + media.address().port; assert.equal((await post('/gif/send', { conv: (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv.id, url: M + '/commons-1.gif', title: 'Cat walking' })).status, 200, 'a Commons GIF is kept in the chat too'); }
|
||||||
|
process.env.TENOR_API_KEY = 'k1'; process.env.GIF_PROVIDER = 'wikimedia'; assert.equal((await j(await call('/gif/status'))).provider, 'wikimedia', 'GIF_PROVIDER picks one even when keys exist'); delete process.env.GIF_PROVIDER; delete process.env.TENOR_API_KEY;
|
||||||
|
// Giphy: G only, and nothing above it slips through
|
||||||
|
delete process.env.TENOR_API_KEY; process.env.GIPHY_API_KEY = 'k2'; r = await j(await call('/gif?q=cat')); assert.match(giphyQ[0], /rating=g/); assert.match(giphyQ[0], /api_key=k2/); assert.deepEqual(r.items.map(x => x.title), ['cat'], 'a result rated above G is dropped even if the service sent it');
|
||||||
|
// a place: search through the hub, and a share with checked coordinates
|
||||||
|
assert.equal((await call('/places?q=ab')).status, 400); r = await j(await call('/places?q=central park')); assert.deepEqual(r.items, [{ title: 'Central Park', sub: 'Manhattan, New York, United States', lat: 40.78, lng: -73.96 }], 'bad rows are dropped');
|
||||||
|
r = await post('/send', { conv: dm.id, share: { kind: 'place', title: 'Central Park', sub: 'New York', lat: 40.78, lng: -73.96, link: 'https://evil.example/' } }); assert.equal(r.status, 200); const sh = (await j(r)).msg.share; assert.equal(sh.link, 'https://maps.apple.com/?ll=40.78,-73.96&q=Central%20Park', 'the Apple Maps link is made by the hub, not taken from the sender'); assert.equal(sh.kind, 'place');
|
||||||
|
for (const bad of [{ lat: 91, lng: 0 }, { lat: 0, lng: 181 }, { lat: 'x', lng: 1 }, { lat: '', lng: '' }, {}]) assert.equal((await post('/send', { conv: dm.id, share: { kind: 'place', title: 'x', ...bad } })).status, 400, JSON.stringify(bad) + ' is not a place');
|
||||||
|
console.log('chat extras ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close(); svc.close(); media.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ for (const f of listed) {
|
|||||||
}
|
}
|
||||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||||
// the pieces the app calls by name across files are there
|
// the pieces the app calls by name across files are there
|
||||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan', 'chGifPicker', 'chPlacePicker', 'chSigRow']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||||
// the watch-stats counting (a pure function)
|
// the watch-stats counting (a pure function)
|
||||||
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
||||||
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Makes the ready-made Office templates in public/templates. Needs: pip install python-docx openpyxl python-pptx. Run: python3 tools/make-office-templates.py public/templates
|
||||||
|
import sys, os
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, RGBColor, Inches
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches as PI, Pt as PP
|
||||||
|
from pptx.dml.color import RGBColor as PC
|
||||||
|
|
||||||
|
OUT = sys.argv[1]; os.makedirs(OUT, exist_ok=True)
|
||||||
|
BLUE = '3D6BF0'
|
||||||
|
|
||||||
|
# ---------------- Word
|
||||||
|
def base(doc, size=11):
|
||||||
|
st = doc.styles['Normal']; st.font.name = 'Calibri'; st.font.size = Pt(size)
|
||||||
|
for s in doc.sections: s.left_margin = s.right_margin = Inches(1); s.top_margin = s.bottom_margin = Inches(0.9)
|
||||||
|
def para(doc, text='', bold=False, size=None, color=None, align=None, after=6, italic=False):
|
||||||
|
p = doc.add_paragraph(); r = p.add_run(text); r.bold = bold; r.italic = italic
|
||||||
|
if size: r.font.size = Pt(size)
|
||||||
|
if color: r.font.color.rgb = RGBColor.from_string(color)
|
||||||
|
if align: p.alignment = align
|
||||||
|
p.paragraph_format.space_after = Pt(after); return p
|
||||||
|
def heading(doc, text, size=13):
|
||||||
|
p = para(doc, text.upper(), bold=True, size=size - 2, color=BLUE, after=3); p.paragraph_format.space_before = Pt(10)
|
||||||
|
return p
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Your name]', bold=True, size=14, after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=0); para(d, '[Phone] · [Email]', after=14)
|
||||||
|
para(d, '[Date]', after=14); para(d, '[Recipient name]', after=0); para(d, '[Title or company]', after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=14)
|
||||||
|
para(d, 'Dear [Name],', after=10)
|
||||||
|
para(d, 'Start with the reason you are writing, in a sentence or two. Keep it friendly and direct.', after=10)
|
||||||
|
para(d, 'Use the middle paragraph for the details: what you need, what happened, or what you are offering. Short paragraphs are easier to read.', after=10)
|
||||||
|
para(d, 'Close by saying what happens next, and thank them for their time.', after=14)
|
||||||
|
para(d, 'Sincerely,', after=30); para(d, '[Your name]', after=0)
|
||||||
|
d.save(f'{OUT}/letter.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 10.5)
|
||||||
|
para(d, '[Your Name]', bold=True, size=24, after=0); para(d, '[City, State] · [Phone] · [Email]', color='666666', after=8)
|
||||||
|
heading(d, 'Summary'); para(d, 'Two or three sentences about who you are, what you do well, and what you are looking for.')
|
||||||
|
heading(d, 'Experience')
|
||||||
|
for i in range(2):
|
||||||
|
para(d, '[Job title], [Employer]', bold=True, after=0); para(d, '[Month Year] – [Month Year]', italic=True, color='666666', after=3)
|
||||||
|
for t in ['What you did and the result it had (use numbers when you can)', 'Something you improved, built or led', 'A skill you used every day']:
|
||||||
|
p = d.add_paragraph(t, style='List Bullet'); p.paragraph_format.space_after = Pt(1)
|
||||||
|
para(d, '', after=4)
|
||||||
|
heading(d, 'Education'); para(d, '[School], [Degree or program]', bold=True, after=0); para(d, '[Year]', italic=True, color='666666')
|
||||||
|
heading(d, 'Skills'); para(d, '[Skill] · [Skill] · [Skill] · [Skill] · [Skill]')
|
||||||
|
d.save(f'{OUT}/resume.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, 'Meeting notes', bold=True, size=22, color=BLUE, after=2); para(d, '[Meeting name]', size=13, after=10)
|
||||||
|
t = d.add_table(rows=4, cols=2); t.style = 'Light Grid Accent 1'
|
||||||
|
for i, (a, b) in enumerate([('Date', '[Date and time]'), ('Where', '[Place or link]'), ('Who was there', '[Names]'), ('Taking notes', '[Name]')]): t.cell(i, 0).text = a; t.cell(i, 1).text = b
|
||||||
|
para(d, '', after=4); heading(d, 'Agenda')
|
||||||
|
for x in ['[First topic]', '[Second topic]', '[Third topic]']: d.add_paragraph(x, style='List Number')
|
||||||
|
heading(d, 'Notes'); para(d, 'What was said and what was decided.', italic=True, color='666666'); para(d, '', after=30)
|
||||||
|
heading(d, 'Action items')
|
||||||
|
t = d.add_table(rows=4, cols=3); t.style = 'Light Grid Accent 1'
|
||||||
|
for i, h in enumerate(['What needs doing', 'Who', 'By when']): t.cell(0, i).text = h
|
||||||
|
d.save(f'{OUT}/meeting-notes.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Family Name] News', bold=True, size=28, color=BLUE, align=WD_ALIGN_PARAGRAPH.CENTER, after=0); para(d, '[Month Year]', align=WD_ALIGN_PARAGRAPH.CENTER, color='666666', after=14)
|
||||||
|
heading(d, 'The big news'); para(d, 'Share the headline: a trip, a birthday, a new job, a new pet. A few friendly sentences is plenty.')
|
||||||
|
heading(d, 'What everyone has been up to')
|
||||||
|
for n in ['[Name]', '[Name]', '[Name]']: para(d, n, bold=True, after=0); para(d, 'A line or two about school, work, hobbies or something funny.', after=8)
|
||||||
|
heading(d, 'Coming up'); t = d.add_table(rows=4, cols=2); t.style = 'Light List Accent 1'
|
||||||
|
t.cell(0, 0).text = 'Date'; t.cell(0, 1).text = 'What'
|
||||||
|
heading(d, 'A favorite moment'); para(d, 'Add a photo here (Insert → Image) and a sentence about it.', italic=True, color='666666')
|
||||||
|
d.save(f'{OUT}/family-newsletter.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Recipe name]', bold=True, size=24, color=BLUE, after=2); para(d, 'Serves [4] · Prep [15 min] · Cook [30 min]', color='666666', after=10)
|
||||||
|
heading(d, 'Ingredients')
|
||||||
|
for x in ['[2 cups flour]', '[1 tsp salt]', '[1 cup milk]', '[2 eggs]']: d.add_paragraph(x, style='List Bullet')
|
||||||
|
heading(d, 'Steps')
|
||||||
|
for x in ['[First step]', '[Second step]', '[Third step]']: d.add_paragraph(x, style='List Number')
|
||||||
|
heading(d, 'Notes'); para(d, 'Where it came from, what to change next time, what goes well with it.', italic=True, color='666666')
|
||||||
|
d.save(f'{OUT}/recipe-card.docx')
|
||||||
|
|
||||||
|
# ---------------- Excel
|
||||||
|
thin = Side(style='thin', color='D0D5DD'); BOX = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
HEAD = PatternFill('solid', fgColor=BLUE); SOFT = PatternFill('solid', fgColor='EEF2FF')
|
||||||
|
def hdr(ws, row, values, widths=None):
|
||||||
|
for i, v in enumerate(values, 1):
|
||||||
|
c = ws.cell(row=row, column=i, value=v); c.font = Font(bold=True, color='FFFFFF'); c.fill = HEAD; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True); c.border = BOX
|
||||||
|
if widths:
|
||||||
|
for i, w in enumerate(widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
|
||||||
|
def title(ws, text, sub=None):
|
||||||
|
ws['A1'] = text; ws['A1'].font = Font(bold=True, size=18, color=BLUE)
|
||||||
|
if sub: ws['A2'] = sub; ws['A2'].font = Font(italic=True, color='666666')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Budget'; title(ws, 'Monthly budget', 'Fill in the planned and actual amounts. The last column does the math.')
|
||||||
|
ws['A4'] = 'Income'; ws['A4'].font = Font(bold=True, size=12)
|
||||||
|
hdr(ws, 5, ['Item', 'Planned', 'Actual', 'Difference'], [30, 14, 14, 14])
|
||||||
|
inc = ['Paycheck', 'Second income', 'Other']
|
||||||
|
for i, n in enumerate(inc):
|
||||||
|
r = 6 + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=C{r}-B{r}')
|
||||||
|
r = 6 + len(inc); ws.cell(r, 1, 'Total income').font = Font(bold=True); ws.cell(r, 2, f'=SUM(B6:B{r-1})'); ws.cell(r, 3, f'=SUM(C6:C{r-1})'); ws.cell(r, 4, f'=C{r}-B{r}'); inc_total = r
|
||||||
|
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
|
||||||
|
start = r + 2; ws.cell(start, 1, 'Expenses').font = Font(bold=True, size=12); hdr(ws, start + 1, ['Item', 'Planned', 'Actual', 'Difference'])
|
||||||
|
exp = ['Rent or mortgage', 'Utilities', 'Groceries', 'Transportation', 'Insurance', 'Phone and internet', 'Eating out', 'Kids and school', 'Health', 'Giving', 'Savings', 'Fun', 'Other']
|
||||||
|
first = start + 2
|
||||||
|
for i, n in enumerate(exp):
|
||||||
|
r = first + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=B{r}-C{r}')
|
||||||
|
last = first + len(exp) - 1; r = last + 1; ws.cell(r, 1, 'Total expenses'); ws.cell(r, 2, f'=SUM(B{first}:B{last})'); ws.cell(r, 3, f'=SUM(C{first}:C{last})'); ws.cell(r, 4, f'=B{r}-C{r}'); exp_total = r
|
||||||
|
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
|
||||||
|
r += 2; ws.cell(r, 1, 'Left over (income − expenses)').font = Font(bold=True, size=12); ws.cell(r, 2, f'=B{inc_total}-B{exp_total}'); ws.cell(r, 3, f'=C{inc_total}-C{exp_total}')
|
||||||
|
for row in ws.iter_rows(min_row=6, max_row=r):
|
||||||
|
for c in row[1:4]: c.number_format = '$#,##0.00;[Red]-$#,##0.00'
|
||||||
|
for c in row[:4]:
|
||||||
|
if c.row not in (start, start + 1, r) and c.value is not None: c.border = BOX
|
||||||
|
ws.freeze_panes = 'A6'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/monthly-budget.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Chores'; title(ws, 'Weekly chore chart', 'Put a name in each box, or a check mark when it is done.')
|
||||||
|
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; hdr(ws, 4, ['Chore'] + days + ['Points'], [26] + [10] * 7 + [10])
|
||||||
|
chores = ['Make bed', 'Set the table', 'Clear the table', 'Dishes / dishwasher', 'Feed the pets', 'Take out trash', 'Tidy bedroom', 'Laundry', 'Vacuum', 'Water plants']
|
||||||
|
for i, n in enumerate(chores):
|
||||||
|
r = 5 + i; ws.cell(r, 1, n)
|
||||||
|
for c in range(1, 10): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(horizontal='center' if c > 1 else 'left')
|
||||||
|
ws.cell(r, 9, f'=COUNTA(B{r}:H{r})')
|
||||||
|
r = 5 + len(chores); ws.cell(r, 1, 'Total').font = Font(bold=True); ws.cell(r, 9, f'=SUM(I5:I{r-1})'); ws.cell(r, 9).font = Font(bold=True)
|
||||||
|
for c in range(1, 10): ws.cell(r, c).fill = SOFT; ws.cell(r, c).border = BOX
|
||||||
|
ws.freeze_panes = 'B5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/chore-chart.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Meals'; title(ws, 'Weekly meal planner', 'Plan the week, then build the shopping list from it.')
|
||||||
|
hdr(ws, 4, ['', 'Breakfast', 'Lunch', 'Dinner', 'Snacks'], [14, 24, 24, 28, 22])
|
||||||
|
for i, d_ in enumerate(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']):
|
||||||
|
r = 5 + i; ws.cell(r, 1, d_).font = Font(bold=True); ws.cell(r, 1).fill = SOFT
|
||||||
|
for c in range(1, 6): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(vertical='top', wrap_text=True)
|
||||||
|
ws.row_dimensions[r].height = 34
|
||||||
|
ws.cell(13, 1, 'Shopping list').font = Font(bold=True, size=12); ws.cell(14, 1, 'Write what you need to buy under here.').font = Font(italic=True, color='666666')
|
||||||
|
ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/meal-planner.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Expenses'; title(ws, 'Expense tracker', 'One line per purchase. The totals below add themselves up.')
|
||||||
|
hdr(ws, 4, ['Date', 'What', 'Category', 'Paid with', 'Amount'], [14, 32, 18, 16, 14])
|
||||||
|
for r in range(5, 45):
|
||||||
|
for c in range(1, 6): ws.cell(r, c).border = BOX
|
||||||
|
ws.cell(r, 1).number_format = 'mmm d, yyyy'; ws.cell(r, 5).number_format = '$#,##0.00'
|
||||||
|
ws['D46'] = 'Total'; ws['D46'].font = Font(bold=True); ws['E46'] = '=SUM(E5:E44)'; ws['E46'].number_format = '$#,##0.00'; ws['E46'].font = Font(bold=True)
|
||||||
|
ws['G4'] = 'By category'; ws['G4'].font = Font(bold=True, color='FFFFFF'); ws['G4'].fill = HEAD; ws['H4'] = 'Total'; ws['H4'].font = Font(bold=True, color='FFFFFF'); ws['H4'].fill = HEAD; ws.column_dimensions['G'].width = 18; ws.column_dimensions['H'].width = 14
|
||||||
|
for i, n in enumerate(['Groceries', 'Gas', 'Eating out', 'Home', 'Kids', 'Health', 'Fun', 'Other']):
|
||||||
|
r = 5 + i; ws.cell(r, 7, n); ws.cell(r, 8, f'=SUMIF($C$5:$C$44,G{r},$E$5:$E$44)'); ws.cell(r, 8).number_format = '$#,##0.00'
|
||||||
|
ws.freeze_panes = 'A5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/expense-tracker.xlsx')
|
||||||
|
|
||||||
|
# ---------------- PowerPoint
|
||||||
|
prs = Presentation(); prs.slide_width = PI(13.333); prs.slide_height = PI(7.5)
|
||||||
|
def slide(title_text, lines=None, layout=1, sub=None):
|
||||||
|
s = prs.slides.add_slide(prs.slide_layouts[layout]); s.shapes.title.text = title_text
|
||||||
|
for p in s.shapes.title.text_frame.paragraphs:
|
||||||
|
for r in p.runs: r.font.color.rgb = PC.from_string(BLUE); r.font.bold = True; r.font.name = 'Calibri'
|
||||||
|
if layout == 0 and sub is not None: s.placeholders[1].text = sub
|
||||||
|
elif lines is not None:
|
||||||
|
tf = s.placeholders[1].text_frame; tf.clear()
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.text = t; p.font.size = PP(26); p.font.name = 'Calibri'
|
||||||
|
return s
|
||||||
|
slide('[Presentation title]', layout=0, sub='[Your name] · [Date]')
|
||||||
|
slide('Agenda', ['[First topic]', '[Second topic]', '[Third topic]', 'Questions'])
|
||||||
|
slide('[A key point]', ['Say it in one clear sentence', 'Back it up with two or three short points', 'Add a picture or a chart on the right (Insert → Image)'])
|
||||||
|
slide('[Another key point]', ['Keep to a few words per line', 'Let the pictures do the talking', 'Practice saying the rest out loud'])
|
||||||
|
slide('Summary', ['What we covered', 'What happens next', 'Who does what'])
|
||||||
|
slide('Thank you', layout=0, sub='[Contact or question prompt]')
|
||||||
|
prs.save(f'{OUT}/simple-presentation.pptx')
|
||||||
|
print(sorted(os.listdir(OUT)))
|
||||||
Reference in new issue
Block a user