Podcast-Oberfläche aus diora-android (Teilen, Transkript erstellen, ✦ Artikel, Artikel-Tab, Einstellungen Transkription), sw CACHE v170

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-09 10:21:42 +01:00
parent d4a47064b2
commit 21cf91a875
3 changed files with 614 additions and 9 deletions

View file

@ -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',

View file

@ -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';

View file

@ -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,