From 21cf91a87505769693d4c63bdf5e91679bbd2534 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 9 Nov 2026 10:21:42 +0100 Subject: [PATCH] =?UTF-8?q?Podcast-Oberfl=C3=A4che=20aus=20diora-android?= =?UTF-8?q?=20(Teilen,=20Transkript=20erstellen,=20=E2=9C=A6=20Artikel,=20?= =?UTF-8?q?Artikel-Tab,=20Einstellungen=20Transkription),=20sw=20CACHE=20v?= =?UTF-8?q?170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 604 +++++++++++++++++++++++++++++- static/podcasts/podcast-ui.css | 17 +- 3 files changed, 614 insertions(+), 9 deletions(-) diff --git a/static/js/sw.js b/static/js/sw.js index c73287e..a249db1 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v169'; +const CACHE = 'diora-v170'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index d0b4ddb..36a5c5c 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js +// Copied from diora-android 7bb865c 2026-11-09: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -273,6 +273,47 @@ t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); } + // ---- sharing an episode: its page from the feed (`link`), else the file / the video's watch URL ---- + + function shareUrl(e) { + const r = e.raw || {}; + const d = e.id > 0 ? storedDetails(e.id) : null; + return r.link || r.episode__link || (d && d.link) || r.audio_url || r.episode__audio_url || (d && d.audio_url) || ''; + } + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(() => true, () => copyTextOld(text)); + } + return Promise.resolve(copyTextOld(text)); + } + + function copyTextOld(text) { + const t = el('textarea'); + t.value = text; + t.setAttribute('readonly', ''); + t.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; + document.body.appendChild(t); + t.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (err) {} + t.remove(); + return ok; + } + + // Android: the system's share sheet. Elsewhere (browser, desktop app): title and link onto the clipboard. + async function shareEpisode(e) { + let url = shareUrl(e); + if (!url && e.id > 0 && navigator.onLine) { // the phone's own list data may not know the link yet + const d = await loadDetails(e.id); + url = (d && (d.link || d.audio_url)) || ''; + } + if (!url) { toast('Diese Folge hat keine Adresse zum Teilen'); return; } + const headline = e.feed ? `${e.title} – ${e.feed}` : e.title; + if (N && N.shareText) { N.shareText(headline, url); return; } + toast(await copyText(headline + '\n' + url) ?'Link kopiert' : 'Kopieren ging nicht'); + } + // GET from our server; a status other than 2xx throws (as a network error does). async function getJson(url) { const r = await fetch(url); @@ -581,6 +622,7 @@ {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)}, !playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, favoriteItem(e), + shareItem(e), !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, @@ -595,6 +637,10 @@ ]); } + function shareItem(e) { + return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)}; + } + function episodeMenu(e, opts) { if (v2()) { listsMenu(e, opts); return; } const queued = inQueue(e.id); @@ -602,6 +648,7 @@ {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, favoriteItem(e), + shareItem(e), !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')}, !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')}, @@ -625,6 +672,7 @@ {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)}, {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, favoriteItem(e), + shareItem(e), N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste', hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)}, !queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')}, @@ -691,6 +739,7 @@ ...dlSettingsItems(), ]), backgroundSyncItem(), + {icon: '✎', label: 'Transkription', hint: 'Whisper-Instanz für Transkripte und Artikel, Modell, Sprache', action: transcriptionSettings}, {icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings}, startPageItem(), group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ @@ -2833,6 +2882,15 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi return true; }; + // A transcript/article notice was tapped (TranscriptNotices.kt): the episode by id. + window.__dioraOpenEpisodeById = async function (id) { + const d = await loadDetails(id); + if (!d) return false; + return window.__dioraPodcastOpenEpisode(Object.assign({}, d, { + feed__id: d.feed && d.feed.id, feed__title: d.feed && d.feed.title, feed__artwork_url: d.feed && d.feed.artwork_url, + })); + }; + window.__dioraPodcastOpenFeed = function (feedId) { const f = feeds().find(x => x.id === Number(feedId)); if (!f) return false; @@ -4328,6 +4386,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.')); return; } + const tr = trOf(id); + trFill(tr, d, !!fresh); if (live && sheet) { const fav = sheet.querySelector('.nui-pod-fav'); if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); } @@ -4338,10 +4398,23 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi if (chapBox) chapterBox(chapBox, chapters, id); const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-notesbody'); + // Jump to a second of the episode (taps on the article's "▶ ab 12:34" links). + const seek = s => { + if (live) N.podcastSeek(s * 1000); + else if (jump) jump(s); + else toast('Zum Anhören die Folge öffnen'); + }; + let current = ''; + let sig = ''; + const sigOf = () => tabList().map(x => x[0]).join() + '|' + + (current === 'transcript' ? (tr.has ? 'has' : trSig(id)) : current === 'article' ? trSig(id) + ':' + tr.article : ''); const show = which => { + current = which; tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which)); body.textContent = ''; - if (which === 'chapters') { + if (which === 'article') { + articlePanel(body, id, seek); + } else if (which === 'chapters') { const ol = el('ol', 'nui-pod-chapters'); chapters.forEach((c, i) => { const li = el('li', 'nui-pod-chapter'); @@ -4372,6 +4445,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi } body.append(notes); } + sig = sigOf(); }; const tab = (which, label) => { const b = el('button', 'nui-pod-subtab', label); @@ -4380,11 +4454,33 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi b.addEventListener('click', () => show(which)); tabs.append(b); }; - if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`); - tab('notes', 'Shownotes'); - if (d.has_transcript) tab('transcript', 'Transkript'); + // The Artikel tab comes first (when there is one); Transkript for every episode with sound + // (not YouTube: the server never fetches its sound), with a button to make one when it is missing. + const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url); + function tabList() { + const list = []; + if (tr.article) list.push(['article', 'Artikel']); + if (chapters.length && !chapBox) list.push(['chapters', `Kapitel (${chapters.length})`]); + list.push(['notes', 'Shownotes']); + if (tr.has || canTranscribe) list.push(['transcript', 'Transkript']); + return list; + } + const draw = want => { + const list = tabList(); + tabs.textContent = ''; + list.forEach(([w, l]) => tab(w, l)); + show(list.some(x => x[0] === want) ? want : list[0][0]); + }; + // Called when the state of the episode's transcript / article changes. + box.trUpdate = select => { + if (select) draw(select); + else if (sigOf() !== sig) draw(current); + }; + trBoxes.add(box); box.append(tabs, body); - show(chapters.length && !chapBox ? 'chapters' : 'notes'); + draw(''); + if (epScreen && epScreen.e.id === id) episodeScreenParts(); + trSchedule(); } // The chapters in the full player: which one plays, previous / next (like AntennaPod's @@ -4455,6 +4551,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while async function showTranscript(body, id, live) { + // None yet: the button to make one (or its progress). + if (!trOf(id).has) { transcriptPanel(body, id); return; } body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …')); let t = transcripts.get(id); if (!t) { @@ -4468,6 +4566,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi } } body.textContent = ''; + if (trOf(id).origin === 'whisper') body.append(el('div', 'nui-pod-meta nui-pod-tr-origin', 'automatisch transkribiert')); const list = el('div', 'nui-pod-transcript'); for (const seg of t.segments || []) { const p = el('p', 'nui-pod-seg'); @@ -4495,6 +4594,493 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi } } + // ---- smart transcription (diora-web docs/transcription.md) ------------------------------ + // The server transcribes (a job per episode, polled every 5 s while it is on screen); the article + // is written on this device with the user's own key (DioraPodcastArticle). One state per episode + // feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button. + + const trStates = new Map(); // episode id -> {job, writing, has, origin, article} + const trBoxes = new Set(); // the details boxes on screen (showDetails) + const trArticles = new Map(); // feed item id -> its item (the article's text) + let trTimer = null; + + async function trFetch(method, url, body) { + const headers = {'Content-Type': 'application/json'}; + if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken(); + const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)}); + let data = null; + try { data = await r.json(); } catch (e) {} + return {ok: r.ok, status: r.status, data}; + } + + function trFill(t, d, fresh) { + if (fresh || !t.has) t.has = !!d.has_transcript; + if (fresh || !t.origin) t.origin = d.transcript_origin || null; + if (fresh || t.article == null) t.article = d.article_item_id || null; + if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null; + } + + function trOf(id) { + let t = trStates.get(id); + if (!t) { + t = {job: null, writing: null, has: false, origin: null, article: null, polling: false}; + trStates.set(id, t); + const d = storedDetails(id); + if (d) trFill(t, d, true); + } + return t; + } + + const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing'); + + // What is going on with the episode right now (a transcription on the server, or the article + // being written here), as the line the buttons show; null when nothing. + function trStatus(id) { + const t = trOf(id); + if (t.writing) return {text: t.writing.text, kind: 'writing'}; + const j = t.job; + if (!trActive(j)) return null; + const text = j.state === 'queued' ? 'Wartet …' + : j.state === 'downloading' ? 'Lade Folge …' + : j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …'; + return {text, kind: 'job'}; + } + + // Changes only when what a view shows changes. + function trSig(id) { + const t = trOf(id); + const st = trStatus(id); + return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : ''); + } + + const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) || + [...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id)); + + function trChanged(id, select) { + for (const b of [...trBoxes]) { + if (!b.isConnected) { trBoxes.delete(b); continue; } + if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select); + } + if (epScreen && epScreen.e.id === id) episodeScreenParts(); + trSchedule(); + } + + // Poll the jobs on screen every five seconds; nothing runs when none is shown. + function trSchedule() { + const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id); + if (ids.length && !trTimer) { + trTimer = setInterval(trTick, 5000); + trTick(); + } else if (!ids.length && trTimer) { + clearInterval(trTimer); + trTimer = null; + } + } + + async function trTick() { + if (document.hidden) return; + for (const [id, t] of [...trStates]) { + if (!trActive(t.job) || !trVisible(id) || t.polling) continue; + t.polling = true; + try { + const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`); + if (r.ok && r.data) await trApply(id, r.data); + } catch (e) {} finally { t.polling = false; } + } + trSchedule(); + } + + async function trApply(id, job) { + const t = trOf(id); + if (!job || job.state === 'none') { t.job = null; trChanged(id); return; } + t.job = job; + if (job.state !== 'done') { trChanged(id); return; } + const want = !!job.want_article; + t.job = null; + t.has = true; + transcripts.delete(id); + await trFresh(id); + trChanged(id); + // diora is open: the article is written right away (when this device has a key; else it waits). + if (want && !t.article && !t.writing) writeArticle(id, {claim: true}); + } + + async function trFresh(id) { + const d = await loadDetails(id); + if (d) trFill(trOf(id), d, true); + return d; + } + + async function startTranscription(id, article) { + const t = trOf(id); + if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; } + let r; + try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; } + if (!r.ok) { + const err = r.data && r.data.error; + const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.' + : err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).' + : r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.'; + t.job = {state: 'error', error: msg, want_article: !!article}; + trChanged(id); + return; + } + const job = r.data || {}; + if (job.state === 'done') { + t.job = null; + t.has = true; + await trFresh(id); + trChanged(id); + if (article) writeArticle(id); + return; + } + t.job = Object.assign({state: 'queued', want_article: !!article}, job); + if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true); + trChanged(id); + } + + async function cancelTranscription(id) { + const t = trOf(id); + try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {} + t.job = null; + trChanged(id); + } + + // The key first: without one the settings open (write() would wait for ever behind the sheet). + async function trKeyReady() { + const AI = window.DioraAI; + if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; } + let has = false; + try { has = await AI.hasKey(); } catch (e) {} + if (!has) { + toast('Für den Artikel braucht diora deinen KI-Schlüssel'); + AI.settings(); + } + return has; + } + + async function articleAction(id) { + const st = trStatus(id); + if (st) { toast(st.text); return; } + if (!(await trKeyReady())) return; + if (trOf(id).has) writeArticle(id); + else startTranscription(id, true); + } + + // Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the + // phone and the desktop don't both write it) and no questions when there is no key. + async function writeArticle(id, opts) { + const claim = !!(opts && opts.claim); + const t = trOf(id); + if (t.writing) return; + const AI = window.DioraAI; + const PA = window.DioraPodcastArticle; + if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; } + if (claim) { + let has = false; + try { has = await AI.hasKey(); } catch (e) {} + if (!has) return; + } + const ctrl = new AbortController(); + t.writing = {text: 'Lade Transkript …', ctrl}; + trChanged(id); + let claimed = false; + try { + if (claim) { + const c = await PA.claim(id); + if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it + claimed = true; + } + let last = 0; + const saved = await PA.write(id, { + signal: ctrl.signal, + onState: s => { + if (!t.writing) return; + t.writing.text = s.text; + const now = Date.now(); + if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); } + }, + }); + if (t.article) trArticles.delete(t.article); + t.article = saved.item_id; + t.writing = null; + await trFresh(id); + if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig'); + trChanged(id, 'article'); + } catch (e) { + t.writing = null; + if (claimed) { try { await PA.release(id); } catch (e2) {} } + trChanged(id); + if (ctrl.signal.aborted) toast('Abgebrochen'); + else if (AI.showError) AI.showError('Artikel schreiben', e); + else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben'); + } + } + + async function removeArticle(id) { + const t = trOf(id); + const ok = typeof customConfirm === 'function' + ? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true; + if (!ok) return; + let r; + try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; } + if (!r.ok) { toast('Entfernen ging nicht'); return; } + if (t.article) trArticles.delete(t.article); + t.article = null; + await trFresh(id); + trChanged(id); + toast('Artikel entfernt'); + } + + // The Transkript tab of an episode without a transcript: start, progress, error. + function transcriptPanel(body, id) { + const t = trOf(id); + body.textContent = ''; + const st = trStatus(id); + const wrap = el('div', 'nui-pod-tr'); + if (st) { + wrap.append(el('div', 'nui-pod-tr-status', st.text)); + if (st.kind === 'job') { + wrap.append(el('div', 'nui-pod-meta', t.job.want_article + ? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.' + : 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.')); + wrap.append(trButton('Abbrechen', () => cancelTranscription(id))); + } else { + wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); })); + } + } else if (t.job && t.job.state === 'error') { + wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.')); + wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true)); + } else { + wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' + + 'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.')); + wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true)); + } + body.append(wrap); + } + + function trButton(label, run, primary) { + const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label); + b.type = 'button'; + b.addEventListener('click', run); + return b; + } + + // The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek. + async function articlePanel(body, id, seek) { + const t = trOf(id); + const itemId = t.article; + body.textContent = ''; + const bar = el('div', 'nui-pod-trbar'); + const st = trStatus(id); + if (st && st.kind === 'writing') { + bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); })); + } else { + if (window.__dioraFeedsOpenItem) { + bar.append(trButton('In Blogs öffnen', () => { + closeNotes(); + closePlayer(); + window.__dioraFeedsOpenItem(itemId); + })); + } + if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id))); + bar.append(trButton('Entfernen', () => removeArticle(id))); + } + const text = el('div', 'nui-pod-notes nui-pod-article'); + body.append(bar, text); + let item = trArticles.get(itemId); + if (!item) { + text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …')); + try { + item = (await getJson(`/feeds/items/${itemId}/`)).item; + if (item) trArticles.set(itemId, item); + } catch (e) {} + if (!text.isConnected || t.article !== itemId) return; + text.textContent = ''; + } + if (!item) { + text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.')); + return; + } + // The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item. + if (item.content) text.innerHTML = item.content; + else text.append(el('p', null, item.summary || '')); + text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank')); + text.addEventListener('click', ev => { + const a = ev.target.closest('a[href^="#t="]'); + if (!a) return; + ev.preventDefault(); + const s = parseInt(a.getAttribute('href').slice(3), 10); + if (s >= 0) seek(s); + }); + } + + // "Neu erstellen" replaces the article: ask first. + async function rewriteArticle(id) { + const ok = typeof customConfirm === 'function' + ? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true; + if (ok) articleAction(id); + } + + // The "✦ Artikel" button of the episode screen, or null where it isn't offered. + function trArticleButton(e) { + const id = e.id; + if (!(id > 0)) return null; + const t = trOf(id); + const d = storedDetails(id) || {}; + const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || ''; + if (e.yt || !url || isYouTube(url)) return null; + const st = trStatus(id); + if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'}; + if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'}; + if (!(window.DioraAI && DioraAI.available())) return null; + return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)}; + } + + // ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ---- + + const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'], + ['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'], + ['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']]; + let trSheet = null; + + function closeTrSettings() { + if (!trSheet) return false; + trSheet.remove(); + trSheet = null; + return true; + } + + async function transcriptionSettings() { + closeTrSettings(); + const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings); + trSheet = made; + const body = el('div', 'nui-pod-trform'); + panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body); + document.body.append(made); + body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …')); + let s; + try { + const r = await trFetch('GET', '/podcasts/transcription/settings/'); + if (!r.ok) throw new Error(r.status); + s = r.data; + } catch (e) { + body.textContent = ''; + body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.')); + return; + } + if (trSheet !== made) return; + drawTranscriptionSettings(body, s, made); + } + + function drawTranscriptionSettings(body, s, made) { + body.textContent = ''; + const field = (label, input) => { + const l = el('label', 'nui-pod-trfield'); + l.append(el('span', null, label), input); + return l; + }; + const text = (type, value, placeholder) => { + const i = el('input', 'search-input'); + i.type = type; + i.value = value == null ? '' : value; + if (placeholder) i.placeholder = placeholder; + i.setAttribute('autocomplete', 'off'); + i.setAttribute('autocapitalize', 'off'); + i.spellcheck = false; + return i; + }; + body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' + + '(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.')); + let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom'; + const engines = el('div', 'nui-pod-subtabs'); + const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean); + const url = text('url', s.url, 'https://whisper.example.org/v1'); + const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional'); + token.setAttribute('autocomplete', 'new-password'); + const urlField = field('Adresse', url); + const tokenField = field('Token', token); + const sync = () => { + engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine)); + urlField.hidden = tokenField.hidden = engine !== 'custom'; + }; + for (const [value, label] of options) { + const b = el('button', 'nui-pod-subtab', label); + b.type = 'button'; + b.dataset.engine = value; + b.addEventListener('click', () => { engine = value; sync(); }); + engines.append(b); + } + body.append(engines, urlField, tokenField); + const model = text('text', s.model, 'z. B. large-v3-turbo'); + const lang = el('select', 'search-input'); + const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]); + for (const [v, l] of langs) { + const o = el('option', null, l); + o.value = v; + lang.append(o); + } + lang.value = s.language || ''; + const par = text('number', s.parallel || 3); + par.min = '1'; + par.max = '8'; + par.inputMode = 'numeric'; + body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par)); + const status = el('div', 'nui-pod-tr-status'); + status.setAttribute('aria-live', 'polite'); + const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); }; + + // Save; the token is sent only when typed (empty keeps it) or removed on purpose. + const save = async removeToken => { + if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; } + const post = { + engine, url: url.value.trim(), model: model.value.trim(), language: lang.value, + parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)), + }; + if (removeToken) post.token = ''; + else if (token.value) post.token = token.value; + const r = await trFetch('POST', '/podcasts/transcription/settings/', post); + if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; } + let next = r.data && 'engine' in r.data ? r.data : null; + if (!next) { + const g = await trFetch('GET', '/podcasts/transcription/settings/'); + next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token}); + } + return next; + }; + const run = fn => async () => { + try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); } + }; + const buttons = el('div', 'nui-pod-trbar'); + buttons.append( + trButton('Speichern', run(async () => { + const next = await save(false); + if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); } + }), true), + trButton('Verbindung testen', run(async () => { + say('Speichere und teste …'); + const next = await save(false); + if (!next) return; + const r = await trFetch('POST', '/podcasts/transcription/test/', {}); + if (trSheet !== made) return; + drawTranscriptionSettings(body, next, made); + const out = body.querySelector('.nui-pod-tr-status'); + const ok = r.ok && r.data && r.data.ok; + out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.` + : 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`); + out.classList.toggle('nui-pod-tr-error', !ok); + }))); + if (s.has_token) { + buttons.append(trButton('Token entfernen', run(async () => { + const next = await save(true); + if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); } + }))); + } + body.append(buttons, status); + if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert')); + sync(); + } + // ---- the episode screen: a tap on a row opens it ---------------------------------------- // Cover, title, podcast (→ its page), date, length, where it stands; one big button to play; // the actions with the swipes' names and icons; chapters (a tap plays from there) and notes. @@ -4552,7 +5138,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi }).catch(() => {}); } document.body.append(notesSheet); - epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')}; + epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')}; episodeScreenParts(); showDetails(box, e.id, false, seconds => playAt(e, seconds)); } @@ -4613,6 +5199,9 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi if (N.podcastDiscard) { act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger'); } + act(null, '⇪', 'Teilen', () => shareEpisode(e)); + const art = trArticleButton(e); + if (art) act(null, '✦', art.label, art.run, art.cls); const fav = e.raw.favorite; act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }), fav ? 'on' : ''); @@ -5828,6 +6417,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi window.__dioraBack = function () { if (closeStats()) return true; if (closeDownloads()) return true; + if (closeTrSettings()) return true; if (closeNotes()) return true; if (closePlayer()) return true; const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts'; diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 622cc01..974ff22 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 5227c26 2026-10-26: the podcast parts of web-native/static/native.css +/* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. The web's own additions are in podcasts-web.css. */ @@ -550,6 +550,21 @@ body.eink .nui-pod-sleep.on { color: #000 !important; } .nui-pod-epact-icon { font-size: 20px; line-height: 22px; } .nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); } .nui-pod-epact-danger { color: var(--red, #e5484d); } +/* Transcript and article of an episode (the Transkript / Artikel tabs, the transcription settings). */ +.nui-pod-tr { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 6px 2px; } +.nui-pod-tr-status { font-size: 14.5px; font-weight: 600; } +.nui-pod-tr-error { font-size: 14px; color: var(--red, #e5484d); } +.nui-pod-tr-origin { margin-bottom: 6px; font-style: italic; } +.nui-pod-trbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } +.nui-pod-trbtn { min-height: 40px; } +.nui-pod-article h2 { font-size: 17px; margin: 1.2em 0 0.2em; } +.nui-pod-article p { margin: 0.5em 0; } +.nui-pod-article blockquote { margin: 0.6em 0; padding-left: 12px; border-left: 3px solid var(--border); } +.nui-pod-trform { display: flex; flex-direction: column; gap: 10px; } +.nui-pod-trfield { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--nui-muted); } +.nui-pod-trfield input, .nui-pod-trfield select { width: 100%; box-sizing: border-box; font-size: 15px; color: inherit; } +.nui-pod-trfield[hidden] { display: none; } +body.eink .nui-pod-tr-error { color: #000; } body.eink .nui-pod-epact { border-color: #000; color: #000; } body.eink .nui-pod-epact-danger { color: #000; } body.eink .nui-pod-chapter.current,