diff --git a/README.md b/README.md index cf15dd7..9c0b118 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ Tap **Visuals** on the Now Playing sheet, the Music card or the Journey FM card It listens to the sound the browser is playing. Where it cannot (iPhone Safari, or audio that comes from another site such as Verse By Verse and LibriVox), it falls back to an "ambient" animation that still keeps time with play and pause, and the top of the screen says which mode is on. +## Offline podcasts, Continue reading and privacy + +- **Save for offline:** on a podcast show, the download button beside an episode keeps it on that device so it plays with no signal. Tap it again to remove it. Saved episodes live in the browser's own storage (not synced between devices) and the browser may clear them if space runs low. +- **Continue reading:** Home lists the ebooks you have started, with progress, next to Continue watching and Continue listening. +- **Privacy:** Settings, Connections has a switch for looking up lyrics and song covers online. Off keeps the artist and title from LRCLIB and iTunes; radio then shows the station logo only. + ## Install on iPhone / iPad (PWA) Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper diff --git a/public/app.js b/public/app.js index e03489d..2d63a00 100644 --- a/public/app.js +++ b/public/app.js @@ -10,7 +10,7 @@ const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different // device, or the separate storage of the iOS home-screen app. Only the music queue stays local. -const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']); +const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; diff --git a/public/index.html b/public/index.html index d07ccb1..9b287e3 100644 --- a/public/index.html +++ b/public/index.html @@ -70,6 +70,7 @@ + diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 3dccbb6..7defea2 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -68,7 +68,7 @@ async function lyrLoad(t, force) { if (!force && lyr.cache.has(id)) return lyrForRadio(t, lyr.cache.get(id)); if (lyrIsRadio(t) && !t.song) return { found: false, error: 'This station does not say which song is playing, so there is nothing to look up.' }; let res = force || lyrIsRadio(t) ? null : await lyrFromPlex(t); - if (!res && cfg.lyrics) { + if (!res && cfg.lyrics && store.get('lookups', true) !== false) { const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' }); try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; } } diff --git a/public/offline.js b/public/offline.js new file mode 100644 index 0000000..6c22461 --- /dev/null +++ b/public/offline.js @@ -0,0 +1,16 @@ +'use strict'; +// Save podcast episodes on this device, so they play with no signal. They live in the browser's own storage (a cache the service worker answers from), +// so they are not synced between devices, and the browser may clear them if the phone runs low on space. +const OFFLINE = 'hub-offline'; +const offUrl = id => '/api/podcast/podcasts/' + encodeURIComponent(id) + '/file'; +const offHas = async id => { try { return !!(await (await caches.open(OFFLINE)).match(offUrl(id))); } catch { return false; } }; +async function offSave(id, onDone) { + if (!('caches' in window)) { toast('This browser cannot save for offline'); return; } + toast('Saving…'); + try { + const r = await fetch(offUrl(id), { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download (' + r.status + ')'); + await (await caches.open(OFFLINE)).put(offUrl(id), r); toast('Saved on this device'); if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); + } catch (e) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); } catch {} toast(e.message || 'Could not save'); } + if (onDone) onDone(); +} +async function offRemove(id, onDone) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); toast('Removed from this device'); } catch {} if (onDone) onDone(); } diff --git a/public/podcast-view.js b/public/podcast-view.js index 80405dc..8dcce60 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -84,7 +84,7 @@ async function pdShow(id) { const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate'))); const paused = !!pdF(show, 'isPaused'), ep = e => `
${esc(pdF(show, 'author') || '')}
@@ -101,6 +101,8 @@ async function pdShow(id) { document.querySelectorAll('.pdep').forEach(r => { const e = byId.get(r.dataset.id); r.querySelector('[data-play]').onclick = () => playFrom(e); + const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; }; + paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); }; r.querySelector('[data-seen]').onclick = async () => { try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); } }; diff --git a/public/radio-view.js b/public/radio-view.js index f722f9e..cf6bfaf 100644 --- a/public/radio-view.js +++ b/public/radio-view.js @@ -3,7 +3,7 @@ // out of it), so stations that only offer plain http still play, and everything plays in the same player as Music. const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts); const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); -const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : ''); +const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title && store.get('lookups', true) !== false ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : ''); const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : ''; const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoArt(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name }); const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; }; diff --git a/public/settings.js b/public/settings.js index 52632e7..96bea3a 100644 --- a/public/settings.js +++ b/public/settings.js @@ -77,6 +77,7 @@ function viewSettings() {