diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c024a..f226fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=&article=&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback). - **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard. ### Fixed diff --git a/Manual.md b/Manual.md index 5ab10ae..f14c760 100644 --- a/Manual.md +++ b/Manual.md @@ -290,6 +290,7 @@ turned into a podcast discussion between two or three voices you pick. playlist player. **Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it. **Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. Older audio, interview-style audio and audio whose text was edited afterwards has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync. + **Share a link:** the *Share* button in the toolbar (the whole magazine), the share button in the playlist header and the one at the top left of driving mode (the current article) share a link of the form `/#s-magazine?doc=&article=
&mode=drive`. Where the browser has a share sheet (phones, and any browser on https) it opens; otherwise the link is copied and a confirmation is announced (on a plain-http address a box with the link is shown to copy by hand). Opening the link goes straight to that article in driving mode with audio, pictures and text; if the browser refuses to start audio without a tap, a large *Tap to play* button appears. A link for the whole magazine opens at its first unfinished article. **Privacy and access:** the link contains only ids, no password or key. It is just an address: whoever opens it needs to reach this app the same way as for any other page (this app has no user accounts; access is controlled by your network or reverse proxy), and an unknown or removed article simply shows a message. Only articles that already have audio can be shared. A link whose parameters are not in the expected form is ignored. **Expected result:** the article plays from its headline to its final paragraph. Page furniture (running heads, page numbers), adverts, sidebars and pull quotes diff --git a/static/js/magazine.js b/static/js/magazine.js index ca88168..0dd6342 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -5286,7 +5286,9 @@ function magPlaylistPlay(articleId, options) { const resumeAt = options?.fromStart || saved.completed ? 0 : Number(saved.seconds || 0); const resume = () => { if (resumeAt > 0 && resumeAt < audio.duration - 1) audio.currentTime = resumeAt; - audio.play().catch(() => {}); + // A browser refuses to start audio without a tap on this page (autoplay policy); a caller that + // opened the player from a link passes onPlayBlocked to show a Play prompt instead of failing silently. + audio.play().catch((e) => { if (options?.onPlayBlocked) options.onPlayBlocked(e); }); }; if (audio.readyState >= 1) resume(); else audio.addEventListener('loadedmetadata', resume, { once: true }); @@ -5572,6 +5574,117 @@ function magOpenDriving() { if (!state.driving) magToggleDriving(); } +// ── Share: a deep link that opens an article (or the whole magazine) in driving mode ─────────────── +// The link is only an address, #s-magazine?doc=&article=&mode=drive, on this app's own origin. It +// carries no credentials and grants nothing: whoever opens it reaches the app the same way as anyone +// else (the app's network / reverse-proxy access rules apply, and every API call is checked as usual). +const MAG_DOC_ID_RE = /^[A-Za-z0-9]{1,40}$/; +const MAG_ARTICLE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +// Strict parse of the URL fragment. Anything that is not exactly this shape is ignored (returns null); the ids +// are only ever matched against the loaded data and never written into markup. +function magParseDeepLink(hash) { + const m = /^#?\/?s-magazine\?([^#]*)$/.exec(String(hash || '').trim()); + if (!m) return null; + let params; + try { params = new URLSearchParams(m[1]); } catch (_) { return null; } + const doc = params.get('doc') || ''; + const article = params.get('article') || ''; + if (!MAG_DOC_ID_RE.test(doc) || params.get('mode') !== 'drive') return null; + if (article && !MAG_ARTICLE_ID_RE.test(article)) return null; + return { doc, article }; +} + +function magDeepLinkUrl(docId, articleId) { + const params = new URLSearchParams({ doc: docId }); + if (articleId) params.set('article', articleId); + params.set('mode', 'drive'); + return location.origin + location.pathname + '#s-magazine?' + params.toString(); +} + +function magShareFallback(url) { + const sheet = $('mag-share-sheet'), input = $('mag-share-link'); + if (!sheet || !input) return; + input.value = url; + sheet.hidden = false; + input.focus(); + input.select(); +} + +function magShareClose() { + const sheet = $('mag-share-sheet'); + if (sheet) sheet.hidden = true; +} + +async function magCopyText(text) { + try { + if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } + } catch (_) { /* fall through to the legacy path */ } + // Plain-http LAN addresses are not secure contexts, where the Clipboard API does not exist. + const box = document.createElement('textarea'); + box.value = text; + box.setAttribute('readonly', ''); + box.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; + document.body.appendChild(box); + box.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (_) { ok = false; } + box.remove(); + return ok; +} + +// Share one article (articleId) or, without it, the magazine (opens at the first unfinished article). +async function magShare(articleId) { + if (!state.doc) return; + let article = null; + if (articleId) { + article = state.articles.find((a) => a.id === articleId); + if (!article || !MAG_ARTICLE_ID_RE.test(articleId)) return; + if (!article.has_audio) return say('Generate this article\'s audio before sharing it', 'err'); + } + const url = magDeepLinkUrl(state.doc.id, article ? article.id : ''); + const title = article ? (article.title || 'Article') : (state.doc.title || 'Magazine'); + const text = (article ? 'Listen to "' + title + '"' : 'Listen to ' + title) + (state.doc.title && article ? ' · ' + state.doc.title : ''); + if (navigator.share) { + try { await navigator.share({ title, text, url }); return; } + catch (e) { if (e && e.name === 'AbortError') return; } // dismissed the sheet; any other failure: copy instead + } + if (await magCopyText(url)) say('Link copied. Anyone who can reach this app can open it in driving mode.', 'success'); + else { say('Copy the link below', 'info'); magShareFallback(url); } +} + +function magShareCurrent() { return magShare(state.playlistCurrent || ''); } + +// The Play prompt shown when the browser blocked autoplay for a deep-linked article. +function magDrivingStartPrompt(show) { + const btn = $('mag-driving-start'); + if (!btn) return; + btn.hidden = !show; + if (show) btn.focus(); +} + +function magDrivingStart() { + const audio = $('mag-playlist-audio'); + if (audio) audio.play().then(() => magDrivingStartPrompt(false)).catch(() => {}); +} + +// Opens the link's magazine, then driving mode on its article. Returns true when driving mode is open. +async function magOpenDeepLink(link) { + if (!link) return false; + await magOpen(link.doc); + if (!state.doc || state.doc.id !== link.doc) return false; // not found or not allowed: magOpen already said so + let article = link.article ? state.articles.find((a) => a.id === link.article) : null; + if (link.article && !article) { say('That article is not in this magazine any more', 'err'); return false; } + if (article && !article.has_audio) { say('That article has no audio yet', 'err'); return false; } + const order = _magPlaylistOrder(); + if (!article) article = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0]; + if (!article) { say('This magazine has no generated audio yet', 'err'); return false; } + magTogglePlaylist(true); + magPlaylistPlay(article.id, { onPlayBlocked: () => magDrivingStartPrompt(true) }); + if (!state.driving) magToggleDriving(); + return true; +} + function renderDriving() { const a = state.article; if (!a) return; @@ -5834,6 +5947,7 @@ function wirePlaylistAudio() { if (!audio || audio.dataset.wired) return; audio.dataset.wired = '1'; audio.addEventListener('ended', _magPlaylistNext); + audio.addEventListener('playing', () => magDrivingStartPrompt(false)); audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); }); audio.addEventListener('loadedmetadata', () => { const a = state.articles.find((x) => x.id === state.playlistCurrent); @@ -5879,6 +5993,10 @@ async function magInit() { if (!$('mag-library-list')) return; // section not mounted magLoadLibrary(); magLoadVoices(); + // A shared link (#s-magazine?doc=..&article=..&mode=drive) wins over "reopen the last magazine". + const link = magParseDeepLink(window._ttsvcDeepLink || location.hash); + window._ttsvcDeepLink = ''; + if (link && await magOpenDeepLink(link)) return; let lastId = null; try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {} if (lastId) { @@ -6007,6 +6125,12 @@ root.magQueueNext = magQueueNext; root.magStopQueue = magStopQueue; root.magToggleDriving = magToggleDriving; root.magOpenDriving = magOpenDriving; +root.magShare = magShare; +root.magShareCurrent = magShareCurrent; +root.magShareClose = magShareClose; +root.magDrivingStart = magDrivingStart; +root.magOpenDeepLink = magOpenDeepLink; +root.magParseDeepLink = magParseDeepLink; root.magDrivingTogglePlayback = magDrivingTogglePlayback; root.magDrivingNext = magDrivingNext; root.magDrivingSkip = magDrivingSkip; diff --git a/static/nav.js b/static/nav.js index 1f8272f..278e47f 100644 --- a/static/nav.js +++ b/static/nav.js @@ -20,6 +20,14 @@ const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library', 's-magazine']; + // A shared deep link's parameters are consumed by the section it names (magazine.js); keep the raw + // fragment, since showing the section rewrites the address bar to the bare section. + if (/^#s-magazine\?/.test(location.hash || '')) { + window._ttsvcDeepLink = location.hash; + // An explicit destination: a first visit must not be diverted to the first-run settings dialog. + window._navRequestedSection = 's-magazine'; + } + function storedSection() { try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; } } @@ -31,6 +39,9 @@ function hashSection() { var raw = String(location.hash || '').replace(/^#/, '').trim(); if (!raw) return ''; + // A deep link carries parameters after the section (#s-magazine?doc=..); the section is what precedes them. + var q = raw.indexOf('?'); + if (q >= 0) raw = raw.slice(0, q); if (raw.indexOf('section=') === 0) raw = raw.slice(8); raw = decodeURIComponent(raw).replace(/^\/?/, ''); return SECTIONS.includes(raw) ? raw : ''; @@ -450,6 +461,13 @@ window.addEventListener('hashchange', function () { var section = hashSection(); + // A link pasted into an already-open tab: open it in place (or, if the magazine module is still + // loading, leave it for the magazine's own startup). + if (/^#s-magazine\?/.test(location.hash || '')) window._ttsvcDeepLink = location.hash; + if (section === 's-magazine' && /^#s-magazine\?/.test(location.hash || '') && typeof window.magParseDeepLink === 'function') { + var link = window.magParseDeepLink(location.hash); + if (link) { showSection(section); window.magOpenDeepLink(link); return; } + } if (section && section !== storedSection()) { if (typeof window.ensureFeatureModules === 'function') window.ensureFeatureModules(section).then(function () { showSection(section); }, function () { showSection(section); }); else showSection(section); diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 21e1ff8..f1365e4 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -116,6 +116,10 @@ title="Open the magazine playlist with large, distraction-free controls"> Driving mode + @@ -179,6 +183,9 @@ + @@ -307,6 +314,12 @@ + +