diora-web/static/js/podcasts-web.js

1652 lines
61 KiB
JavaScript
Raw Normal View History

Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
/**
* Podcasts in the browser: the phone app's podcast page on top of a browser
* version of what the app does in Kotlin.
*
* The page itself is diora-android's web-native/static/native-podcast.js,
* copied unchanged to /static/podcasts/ by tools/sync_podcast_ui.py. It talks
* to its host only through a small API (window.DioraNative in the app,
* window.DioraPodcastHost here) and hears back through window.__dioraPodcasts,
* __dioraPodcastsRefreshed and __dioraPlayer. This file is that host:
*
* - the state (feeds, queue, inbox, settings) from GET /podcasts/state/,
* kept in localStorage for a fast, offline-capable start (Podcasts.kt);
* - every change applied to that copy at once and sent to the server in
* order through an outbox that survives reloads and going offline;
* - a player of its own (not app.js's radio element) whose playlist is the
* queue, with the app's bookkeeping (RadioService.EpisodeTracker): resume,
* intro/outro, progress every 10 s and on pause, "played" near the end,
* moving on by itself, rewinding a little after a pause, per-podcast
* speed and volume, and the sleep timer (SleepTimer.kt);
* - lock-screen and media-key controls through navigator.mediaSession;
* - this device's playback settings in localStorage (PlaybackPrefs.kt).
*
* Radio and podcasts never play at the same time: starting an episode stops
* the radio (app.js stopPlayback), starting a station puts the episode away.
*
* In the app (and the desktop app) window.DioraNative exists and this file
* does nothing. Written without ?. and ?? so old WebViews can still parse it.
*/
(function () {
'use strict';
if (window.DioraNative) return;
if (typeof IS_AUTHENTICATED === 'undefined' || !IS_AUTHENTICATED) return;
if (!document.getElementById('tab-podcasts')) return;
const UI_SCRIPT = '/static/podcasts/native-podcast.js';
const UI_STYLES = ['/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css'];
// ---- storage ------------------------------------------------------------------
const USER = String(window.USER_ID || '');
function load(name, fallback) {
try {
const v = localStorage.getItem('dpw_' + name + '_' + USER);
return v ? JSON.parse(v) : fallback;
} catch (e) {
return fallback;
}
}
function store(name, value) {
try { localStorage.setItem('dpw_' + name + '_' + USER, JSON.stringify(value)); } catch (e) {}
}
function parse(s, fallback) {
try { return typeof s === 'string' ? JSON.parse(s) : s; } catch (e) { return fallback; }
}
function csrf() {
return typeof getCsrfToken === 'function' ? getCsrfToken() : '';
}
// ---- state and outbox (Podcasts.kt) ----------------------------------------------
const POSITIONS = ['back', 'front', 'after_current'];
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration'];
function empty() {
return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}};
}
let state = load('state', null) || empty();
let outbox = load('outbox', []);
function save() {
store('state', state);
store('outbox', outbox);
}
function changed() {
if (window.__dioraPodcasts) window.__dioraPodcasts(JSON.stringify(state));
}
function edit(block) {
block(state);
save();
changed();
}
function queueOp(path, body, key) {
if (key) outbox = outbox.filter(op => op.key !== key);
const op = {path, body};
if (key) op.key = key;
outbox.push(op);
}
let flushing = null;
// Send queued changes in order. False when the server can't be reached.
function flush() {
if (flushing) return flushing;
flushing = sendAll().then(ok => { flushing = null; return ok; }, () => { flushing = null; return false; });
return flushing;
}
async function sendAll() {
while (outbox.length) {
const op = outbox[0];
let r;
try {
r = await fetch(op.path, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify(op.body),
});
} catch (e) {
return false;
}
if (r.status === 401 || r.status >= 500) return false;
// Another 4xx: the server refuses this change (episode gone …); drop it
// rather than block everything behind it.
const i = outbox.indexOf(op);
if (i >= 0) outbox.splice(i, 1);
save();
}
return true;
}
// Fetch the state in one request (with fetchFeeds the server fetches all
// feeds first). Changes not sent yet go first; while some are still
// pending, the local copy stays.
async function refresh(fetchFeeds) {
try {
if (fetchFeeds) {
if (!(await flush())) return false;
await fetch('/podcasts/feeds/refresh-all/', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: '{"max_age":60}',
});
}
// Progress is saved every few seconds while an episode plays; try again
// when it slipped in while the state was on its way.
for (let attempt = 0; attempt < 3; attempt++) {
if (!(await flush())) return false;
const r = await fetch('/podcasts/state/?inbox_limit=200', {credentials: 'same-origin', cache: 'no-store'});
if (r.status !== 200) return false;
const fresh = await r.json();
if (outbox.length) continue;
state = fresh;
save();
changed();
followSavedPlace();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
return true;
}
return false;
} catch (e) {
return false;
}
}
const queueOf = s => s.queue || (s.queue = []);
const queueIds = s => queueOf(s).map(q => q.episode__id);
function entry(id) {
return queueOf(state).find(q => q.episode__id === id) || null;
}
function feed(id) {
return (state.feeds || []).find(f => f.id === id) || null;
}
// The queue order a "keep sorted" rule gives (Podcasts.keepSortedOrder; the server sorts the same way).
function keepSortedOrder(entries, rule) {
const date = o => (o.episode__pub_date ? String(o.episode__pub_date) : null);
const left = o => (o.episode__duration_seconds || 0) - (o.position_seconds || 0);
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const byDate = desc => (a, b) => {
const da = date(a), db = date(b);
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return desc ? cmpStr(db, da) : cmpStr(da, db);
};
let cmp;
if (rule === 'new') cmp = byDate(true);
else if (rule === 'old') cmp = byDate(false);
else if (rule === 'short') cmp = (a, b) => left(a) - left(b);
else if (rule === 'long') cmp = (a, b) => left(b) - left(a);
else if (rule === 'feed') {
cmp = (a, b) => cmpStr(String(a.episode__feed__title || '').toLowerCase(), String(b.episode__feed__title || '').toLowerCase())
|| byDate(true)(a, b);
} else return entries.map(o => o.episode__id);
return entries.slice().sort(cmp).map(o => o.episode__id); // Array.sort is stable
}
function setQueueOrder(s, order) {
const byId = new Map(queueOf(s).map(q => [q.episode__id, q]));
const rule = (s.settings && s.settings.keep_sorted) || '';
const final = rule ? keepSortedOrder(order.map(id => byId.get(id)).filter(Boolean), rule) : order;
s.queue = final.map(id => byId.get(id)).filter(Boolean);
s.queue.forEach((q, i) => { q.position = i; });
}
// An inbox/feed episode as a queue entry (the server's queue/ field names).
function asQueueEntry(ep) {
if (ep.episode__id != null) return ep;
return {
episode__id: ep.id,
episode__title: ep.title || '',
episode__audio_url: ep.audio_url || '',
episode__duration_seconds: ep.duration_seconds || 0,
episode__artwork_url: ep.artwork_url || '',
episode__pub_date: ep.pub_date || null,
episode__feed__id: ep.feed__id != null ? ep.feed__id : ep.feed_id,
episode__feed__title: ep.feed__title || ep.feed_title || '',
episode__feed__artwork_url: ep.feed__artwork_url || '',
position_seconds: ep.position_seconds || 0,
};
}
function removeFromInbox(s, ids) {
s.inbox = (s.inbox || []).filter(o => !ids.has(o.id));
}
function markInQueue(s, ids, inQueue) {
for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue;
}
function forEpisode(s, id, block) {
for (const o of queueOf(s)) if (o.episode__id === id) block(o);
for (const o of s.inbox || []) if (o.id === id) block(o);
}
function enqueue(episodes, where, currentId) {
if (!episodes.length) return;
const pos = POSITIONS.indexOf(where) >= 0 ? where : (state.settings && state.settings.enqueue_position) || 'back';
edit(s => {
const order = queueIds(s);
const seen = new Set(order);
const fresh = [];
for (const e of episodes.map(asQueueEntry)) {
if (!seen.has(e.episode__id)) { seen.add(e.episode__id); fresh.push(e); }
}
if (!fresh.length) return;
for (const e of fresh) queueOf(s).push(e);
let at = order.length;
if (pos === 'front') at = 0;
else if (pos === 'after_current') {
const i = currentId ? order.indexOf(currentId) : -1;
at = i >= 0 ? i + 1 : 0;
}
const ids = fresh.map(e => e.episode__id);
setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at)));
removeFromInbox(s, new Set(ids)); // queued means no longer new
const body = {episode_ids: ids, where: pos};
if (currentId) body.current_episode_id = currentId;
queueOp('/podcasts/queue/add/', body);
});
}
function dequeueAll(ids) {
edit(s => {
const gone = new Set(ids);
setQueueOrder(s, queueIds(s).filter(id => !gone.has(id)));
markInQueue(s, gone, false);
for (const id of ids) queueOp('/podcasts/queue/remove/', {episode_id: id});
});
}
function reorder(order) {
edit(s => {
const now = queueIds(s);
const wanted = [];
for (const id of order) if (now.indexOf(id) >= 0 && wanted.indexOf(id) < 0) wanted.push(id);
const final = wanted.concat(now.filter(id => wanted.indexOf(id) < 0));
if (final.join() === now.join()) return;
setQueueOrder(s, final);
queueOp('/podcasts/queue/reorder/', {order: queueIds(s)});
});
}
function move(id, index) {
edit(s => {
const order = queueIds(s);
const at = order.indexOf(id);
if (at < 0) return;
order.splice(at, 1);
order.splice(Math.max(0, Math.min(index, order.length)), 0, id);
setQueueOrder(s, order);
queueOp('/podcasts/queue/reorder/', {order});
});
}
function saveProgress(id, seconds) {
forEpisode(state, id, o => { o.position_seconds = seconds; });
if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new
queueOp('/podcasts/progress/save/', {episode_id: id, position_seconds: seconds}, 'progress:' + id);
save();
// No re-render: progress changes every few seconds while playing.
}
// Finished or marked by hand: played and out of the inbox; out of the queue
// too unless leaveQueue is false (the user's setting).
function markPlayedAll(ids, played, leaveQueue) {
edit(s => {
for (const id of ids) queueOp('/podcasts/progress/mark-played/', {episode_id: id, played});
if (!played) return;
const set = new Set(ids);
removeFromInbox(s, set);
for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; });
if (leaveQueue) {
const inQueue = queueIds(s).filter(id => set.has(id));
if (inQueue.length) {
setQueueOrder(s, queueIds(s).filter(id => !set.has(id)));
for (const id of inQueue) queueOp('/podcasts/queue/remove/', {episode_id: id});
}
}
});
}
function dismiss(ids) {
edit(s => {
removeFromInbox(s, new Set(ids));
queueOp('/podcasts/progress/dismiss/', {episode_ids: ids});
});
}
function setSettings(changes) {
edit(s => {
const settings = s.settings || (s.settings = {});
const body = {};
for (const name of ['enqueue_position', 'auto_queue_position']) {
if (POSITIONS.indexOf(changes[name]) >= 0) settings[name] = body[name] = changes[name];
}
if ('keep_sorted' in changes && SORT_RULES.indexOf(changes.keep_sorted) >= 0) {
settings.keep_sorted = body.keep_sorted = changes.keep_sorted;
if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once
}
if (Object.keys(body).length) queueOp('/podcasts/settings/', body);
});
}
// A podcast's own settings, kept on the server so every device has them.
function setFeedSettings(feedId, changes) {
edit(s => {
const f = (s.feeds || []).find(x => x.id === feedId);
if (!f) return;
const body = {};
for (const key of Object.keys(changes)) {
if (FEED_SETTINGS.indexOf(key) < 0) continue;
f[key] = body[key] = changes[key];
}
if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue';
if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body);
});
if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); }
}
function setAutoQueue(feedId, on) {
edit(s => {
const f = (s.feeds || []).find(x => x.id === feedId);
if (f) f.auto_queue = on;
queueOp('/podcasts/feeds/' + feedId + '/set-auto-queue/', {auto_queue: on});
});
}
// ---- this device's playback settings (PlaybackPrefs.kt) --------------------------
const CHOICES = {
skip_back: [5, 10, 15, 30],
skip_forward: [10, 15, 30, 45, 60],
smart_played: [0, 15, 30, 60, 120, 300, 600],
};
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
sleep_last: 15, sleep_auto: false}, load('prefs', {}));
function setPref(name, value) {
if (name === 'speed') {
if (!(value >= 0.5 && value <= 3)) return;
prefs.speed = value;
} else if (CHOICES[name]) {
if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
prefs[name] = Math.round(value);
} else if (name === 'remove_played') {
prefs.remove_played = !!value;
} else {
return; // skip_silence, headset_next: nothing a browser can do
}
store('prefs', prefs);
}
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() {
return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward,
remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}};
}
function speedFor(feedId) {
const f = feed(feedId);
return f && f.playback_speed > 0 ? f.playback_speed : prefs.speed;
}
const hasOwnSpeed = feedId => { const f = feed(feedId); return !!(f && f.playback_speed > 0); };
const introMs = feedId => { const f = feed(feedId); return ((f && f.skip_intro) || 0) * 1000; };
const outroMs = feedId => { const f = feed(feedId); return ((f && f.skip_outro) || 0) * 1000; };
const volumeFor = feedId => { const f = feed(feedId); return f && f.volume > 0 ? f.volume : 1; };
// The page's per-podcast chips speak the app's local format: {speed, intro, outro, volume}.
function feedPrefs(feedId, changes) {
const body = {};
if ('speed' in changes) body.playback_speed = changes.speed > 0 ? changes.speed : null;
if ('intro' in changes) body.skip_intro = Math.round(changes.intro || 0);
if ('outro' in changes) body.skip_outro = Math.round(changes.outro || 0);
if ('volume' in changes) body.volume = changes.volume > 0 ? changes.volume : 1;
setFeedSettings(feedId, body);
}
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
const FADE_MS = 10000;
const MAX_GAP_MS = 10000;
const sleep = {
leftMs: 0,
episodes: 0,
lastTick: 0,
get active() { return this.leftMs > 0 || this.episodes > 0; },
set(minutes, episodes) {
this.leftMs = minutes * 60000;
this.episodes = minutes > 0 ? 0 : episodes;
this.lastTick = 0;
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
setFade(1);
},
extend(minutes) {
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
this.leftMs += minutes * 60000;
setFade(1);
},
tick(playing) {
const now = Date.now();
const delta = this.lastTick ? now - this.lastTick : 0;
this.lastTick = now;
if (this.leftMs <= 0 || !playing) return;
// A gap means nothing was ticking (paused, tab asleep): not playing time.
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
if (this.leftMs <= 0) {
this.leftMs = 0;
setFade(1);
pause();
stopRadio(); // the radio sleeps too (it plays in app.js's element)
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
return;
}
if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
},
// An episode finished: true when playing should stop here.
episodeFinished() {
if (this.episodes <= 0) return false;
this.episodes--;
return this.episodes === 0;
},
queueEnded() { this.episodes = 0; },
// An episode starts playing: at night, the timer may switch itself on.
maybeAutoEnable() {
if (this.active || !prefs.sleep_auto) return;
const hour = new Date().getHours();
if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
},
json() {
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15};
},
};
// ---- the player (RadioService.EpisodeTracker) ---------------------------------------
const audio = new Audio(); // the radio keeps app.js's own element
audio.preload = 'auto';
let cur = null; // queue entry (a copy) of the episode in the player
let loaded = false; // its address is set on the audio element
let wantPlay = false; // play when ready (what the page shows as playing)
let startAt = null; // ms to start from when it gets loaded
let lastPos = 0; // ms
let lastDur = 0; // ms
let lastSaved = 0;
let pausedAt = 0;
let outroDone = null; // episode whose outro was skipped already
let fade = 1; // the sleep timer's fade-out
let buffering = false;
let error = null;
let sessionFor = null; // episode the media session shows
let mustReach = null; // ms the first seek after loading should have reached
const curId = () => (cur ? cur.episode__id : null);
const curFeed = () => (cur ? cur.episode__feed__id : null);
function durMs() {
if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000;
return lastDur || ((cur && cur.episode__duration_seconds) || 0) * 1000;
}
function posMs() {
if (mustReach != null) return mustReach; // the start place, until it's confirmed
if (loaded && audio.readyState > 0) return audio.currentTime * 1000;
return startAt != null ? startAt : lastPos;
}
// Where this episode picks up: its place (the queue knows it best), past the podcast's intro.
function resumeMs() {
const q = cur ? entry(cur.episode__id) : null;
const resume = ((q || cur || {}).position_seconds || 0) * 1000;
return Math.max(resume > 5000 ? resume : 0, introMs(curFeed()));
}
// Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished.
function almostEnded() {
const dur = durMs();
return dur > 0 && posMs() >= dur - Math.max(prefs.smart_played * 1000, 1000);
}
function nextEntry() {
const q = queueOf(state);
const at = q.findIndex(e => e.episode__id === curId());
if (at >= 0) return q[at + 1] || null;
return q.length && q[0].episode__id !== curId() ? q[0] : null;
}
function saveNow(id, send) {
lastSaved = Date.now();
saveProgress(id, Math.floor(posMs() / 1000));
if (send) flush();
}
// An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
function finished(id) {
markPlayedAll([id], true, prefs.remove_played);
flush();
return sleep.episodeFinished();
}
function setFade(f) {
fade = f;
applyVolume();
}
// The bar's volume slider (app.js setVolume, 0-255): one level for radio and podcasts.
function barVolume() {
const s = document.getElementById('volume');
const v = s ? parseInt(s.value, 10) : NaN;
return isFinite(v) ? Math.max(0, Math.min(255, v)) / 255 : 1;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
function applyVolume() {
// Louder than 1 needs an audio graph, which episodes from other servers can't feed: 1 at most.
audio.volume = Math.max(0, Math.min(1, Math.min(volumeFor(curFeed()), 1) * fade * barVolume()));
}
if (typeof window.setVolume === 'function') {
const setVolume = window.setVolume;
window.setVolume = function () {
const r = setVolume.apply(this, arguments);
applyVolume();
return r;
};
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
function applyKind() {
const rate = speedFor(curFeed());
audio.defaultPlaybackRate = rate;
if (audio.playbackRate !== rate) audio.playbackRate = rate;
audio.preservesPitch = true;
applyVolume();
}
function storePlayer() {
store('player', cur ? {entry: cur} : null);
}
// Load the episode's audio, starting where it should.
// (startAt is applied once the metadata is there, see 'loadedmetadata'.)
function prepare() {
startAt = startAt != null ? startAt : resumeMs();
lastPos = startAt;
loaded = true;
error = null;
buffering = true;
audio.src = cur.episode__audio_url;
applyKind();
}
function unload() {
loaded = false;
mustReach = null;
audio.pause();
audio.removeAttribute('src');
try { audio.load(); } catch (e) {}
}
// Move to another episode (onMediaItemTransition): the one we leave counts as
// finished when it ended (auto) or is left within its last seconds; else it keeps its place.
function goTo(e, auto, play) {
const prev = curId();
let stop = false;
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev);
else saveNow(prev, true);
}
unload();
cur = Object.assign({}, e);
startAt = null;
pausedAt = 0;
buffering = false;
error = null;
lastDur = 0;
lastPos = resumeMs();
storePlayer();
if (play && !stop) resume();
else { wantPlay = false; report(); syncSoon(); }
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
// A pause rewinds a little on the way back in, to find the thread again.
function catchUp() {
if (!pausedAt) return;
const paused = Date.now() - pausedAt;
pausedAt = 0;
mustReach = null;
const back = paused > 24 * 3600000 ? 20000 : paused > 3600000 ? 10000 : paused > 60000 ? 3000 : 800;
try { audio.currentTime = Math.max(0, audio.currentTime - back / 1000); } catch (e) {}
}
function resume() {
if (!cur) return;
stopRadio();
if (!loaded) prepare();
else catchUp();
wantPlay = true;
sync.start = true;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
applyKind();
setSession();
const p = audio.play();
if (p && p.catch) {
p.catch(err => {
if (err && err.name === 'AbortError') return; // a newer load or pause came first
wantPlay = false;
buffering = false;
if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen');
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
});
}
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
function pause() {
wantPlay = false;
if (!audio.paused) audio.pause();
pausedAt = pausedAt || Date.now();
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
function seek(ms) {
if (!cur) return;
ms = Math.max(0, ms);
mustReach = null;
if (loaded && audio.readyState > 0) {
try { audio.currentTime = ms / 1000; } catch (e) {}
} else {
startAt = ms;
}
lastPos = ms;
// A place chosen while paused is kept at once (playing, it's saved every 10 s).
if (!wantPlay) saveNow(cur.episode__id, true);
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
function skip(forward) {
const pos = posMs();
if (!forward) { seek(pos - prefs.skip_back * 1000); return; }
const to = pos + prefs.skip_forward * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
}
function next() {
const n = nextEntry();
if (n) goTo(n, false, wantPlay);
}
function playEpisode(id) {
if (curId() === id) { resume(); return; }
const e = entry(id);
if (e) goTo(e, false, true);
}
// The podcast's outro is skipped: the episode counts as finished there.
function skipOutro(id) {
if (outroDone === id) return;
const outro = outroMs(curFeed());
const dur = durMs();
if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
outroDone = id;
const n = nextEntry(); // before finished() takes this one out of the queue
if (finished(id) || !n) pause();
else goTo(n, false, true);
}
function onEnded() {
const id = curId();
if (id == null) return;
const n = nextEntry();
if (n) { goTo(n, true, true); return; }
if (id !== outroDone) finished(id);
outroDone = id;
sleep.queueEnded();
wantPlay = false;
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
// A browser that pauses the element while it loads (power saving) may drop
// that first seek: check it once the audio can play.
const reached = () => {
if (mustReach == null || audio.seeking) return;
if (Math.abs(audio.currentTime * 1000 - mustReach) > 3000) {
try { audio.currentTime = mustReach / 1000; } catch (e) {}
}
mustReach = null;
};
audio.addEventListener('canplay', reached);
audio.addEventListener('playing', () => {
reached();
buffering = false;
if (!wantPlay) wantPlay = true;
sleep.maybeAutoEnable();
report();
});
audio.addEventListener('waiting', () => { buffering = true; report(); });
audio.addEventListener('canplay', () => { if (buffering && audio.paused) { buffering = false; report(); } });
audio.addEventListener('pause', () => {
if (!loaded || audio.ended) return;
// Paused here, by the lock screen, or by the system (headphones out …).
const was = wantPlay;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
wantPlay = false;
pausedAt = pausedAt || Date.now();
if (cur) saveNow(cur.episode__id, true);
report();
if (was) syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
});
audio.addEventListener('ended', onEnded);
audio.addEventListener('loadedmetadata', () => {
if (startAt != null) {
const at = startAt;
startAt = null;
if (at > 0) { try { audio.currentTime = at / 1000; } catch (e) {} mustReach = at; }
}
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
applyKind();
report();
});
audio.addEventListener('error', () => {
if (!loaded || !audio.getAttribute('src')) return;
error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0);
wantPlay = false;
buffering = false;
toast('Diese Folge lässt sich gerade nicht abspielen');
report();
syncSoon();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
});
// Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer.
setInterval(() => {
const id = curId();
const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2;
if (id != null && loaded && audio.readyState > 0) {
lastPos = audio.currentTime * 1000;
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
}
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
// While playing, the shared player hears from us every 10 s (its place, and that we're alive).
if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport();
// Another device's place moves on in the bar too.
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}, 1000);
// ---- radio and podcasts take turns ---------------------------------------------------
function stopRadio() {
try {
if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false);
} catch (e) {}
}
// A station starts: the episode keeps its place and leaves the player (the app does the same).
function putAway() {
if (!cur) return;
if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
wantPlay = false;
unload();
cur = null;
storePlayer();
report();
if (sessionFor != null && 'mediaSession' in navigator) {
// The station sets its own handlers; these two it doesn't know about.
try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {}
try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {}
sessionFor = null;
}
}
if (typeof window.playStation === 'function') {
const playStation = window.playStation;
window.playStation = function () {
putAway();
const r = playStation.apply(this, arguments);
radioChanged();
sync.start = true;
syncSoon();
return r;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
};
}
// ---- the radio in the same player (in the app native-radio.js does this) ----------
// The podcast page's bar and full player show the station too; app.js keeps
// playing it, its own bar keeps only volume, votes, focus mode and timer.
function radioChanged() {
if (window.__dioraRadioChanged) window.__dioraRadioChanged();
}
function radioSaved(url) {
return [...document.querySelectorAll('#saved-tbody tr[data-url]')].some(r => r.dataset.url === url);
}
function radioLogo(st) {
const all = [].concat(typeof INITIAL_SAVED !== 'undefined' ? INITIAL_SAVED : [],
typeof INITIAL_FEATURED !== 'undefined' ? INITIAL_FEATURED : []);
const s = all.find(x => x && x.url === st.url);
return (s && s.favicon_url) || '';
}
const radioPlaying = () => typeof isPlaying !== 'undefined' && !!isPlaying;
window.__dioraRadioNow = function () {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
if (!st) return null;
return {
name: st.name || '',
logo: radioLogo(st),
track: typeof currentTrack !== 'undefined' ? currentTrack : '',
playing: radioPlaying(),
buffering: false,
saved: radioSaved(st.url),
};
};
// Stop keeps the station (like a paused episode): the bar stays with ▶.
window.__dioraRadioToggle = () => (radioPlaying() ? stopPlayback(false) : togglePlayStop());
window.__dioraRadioSave = () => saveCurrentStation();
for (const name of ['stopPlayback', 'updateNowPlayingUI', 'saveCurrentStation']) {
const fn = window[name];
if (typeof fn !== 'function') continue;
window[name] = function () {
const r = fn.apply(this, arguments);
if (r && typeof r.then === 'function') r.then(radioChanged, radioChanged);
else radioChanged();
return r;
};
}
// The radio stopping is news for the shared player too.
if (typeof window.stopPlayback === 'function') {
const stopPlayback = window.stopPlayback;
window.stopPlayback = function () {
const was = radioPlaying();
const r = stopPlayback.apply(this, arguments);
if (was) syncSoon();
return r;
};
}
const savedRows = document.getElementById('saved-tbody');
if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true});
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// Player sync: the station another device played last becomes the current
// one here, without playing (the bar shows it with ▶).
window.__dioraAdoptStation = function (st) {
if (!st || !st.url || radioPlaying() || typeof currentStation === 'undefined') return;
currentStation = {url: st.url, name: st.name || '', id: st.id || null};
currentTrack = '';
const name = document.getElementById('now-playing-station');
if (name) name.textContent = currentStation.name;
const track = document.getElementById('now-playing-track');
if (track) track.textContent = '';
const b = document.getElementById('play-stop-btn');
if (b) {
b.style.display = '';
b.textContent = '▶ Play';
b.classList.remove('playing');
}
radioChanged();
};
// ---- player sync: one player for all devices (like Spotify Connect) ------------------
// The server keeps what plays (/podcasts/player/, see podcasts/views.py). The
// device that starts playing becomes the active one and stays it while it is
// online, also paused. Every other device stops its own playback and shows
// the active one's as "remote" (playerJson): its buttons send commands
// there, "Hier abspielen" plays it here from its place, the device sheet
// moves it anywhere (transfer). When the active device is gone, what it
// played last is loaded here, paused. In the app PlayerSync.kt does this.
const REPORT_EVERY_MS = 10000;
const OLD_COMMAND_MS = 60000; // a command this old (the page was away) is only acknowledged
function deviceId() {
let id = '';
try { id = localStorage.getItem('diora_device_id') || ''; } catch (e) {}
if (!id) {
id = 'web-' + Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
try { localStorage.setItem('diora_device_id', id); } catch (e) {}
}
return id;
}
function browserName() {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web';
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows'
: /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';
return os ? browser + '/' + os : browser;
}
const DEVICE_ID = deviceId();
const DEVICE_NAME = 'Browser (' + browserName() + ')';
const sync = {
state: null, // the server's state, as last taken in
heardAt: 0, // when it came (this clock only measures the time since)
later: null, // the newest state that came while a report of ours was on its way
ownAt: load('sync_at', ''), // server time of our last report: what the others did before is old
sending: 0, // reports on their way
timer: 0, // a report about to go
lastSent: 0,
quiet: false, // playback changes the sync makes itself: no reports of their own
doneCmd: null, // the command the next report says is done
start: false, // playback was just started here (by the user, a command): we take over
executed: new Set(),
hint: null, // what a command of ours will change, shown until the device reports
};
const busy = () => sync.sending > 0 || !!sync.timer;
function isoMs(iso) {
const t = Date.parse(iso || '');
return isFinite(t) ? t : 0;
}
// The server's clock now: its time when the state came plus what has passed since.
function serverNow(st) {
return isoMs(st.server_time) + (Date.now() - sync.heardAt);
}
// Where the state's item is now: while it plays, its last place plus the time since.
function placeOf(st) {
let pos = st.position_ms || 0;
if (st.playing && st.active_online && st.updated_at) {
pos += Math.max(0, serverNow(st) - isoMs(st.updated_at)) * (st.speed || 1);
}
const dur = st.episode ? (st.episode.duration_seconds || 0) * 1000 : 0;
return Math.round(dur > 0 ? Math.min(pos, dur) : pos);
}
// Another device is the active one, it is online and has something.
function otherActive(st) {
return !!(st && st.device_id && st.device_id !== DEVICE_ID && st.active_online &&
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
}
// The page's player.remote: what the other device plays.
function remoteJson() {
const st = sync.state;
if (!otherActive(st)) return null;
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
const radio = st.kind === 'radio';
if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
o.title = o.station.name;
} else {
const e = st.episode;
o.episodeId = e.id;
o.feedId = e.feed__id || 0;
o.title = plain(e.title);
o.feed = plain(e.feed__title);
o.artwork = e.artwork_url || e.feed__artwork_url || '';
o.dur = (e.duration_seconds || 0) * 1000;
o.pos = placeOf(st);
}
// A command of ours shows at once, until the device reports back.
const h = sync.hint;
if (h && h.updatedAt === st.updated_at && Date.now() < h.until) {
o.playing = h.playing;
if (!radio) {
o.pos = Math.round(h.pos + (o.playing ? (Date.now() - h.at) * (st.speed || 1) : 0));
o.pos = Math.max(0, o.dur > 0 ? Math.min(o.pos, o.dur) : o.pos);
}
}
return o;
}
// The page's player.devices: the server's list, with this browser marked (and in it).
function devicesJson() {
const st = sync.state;
const list = ((st && st.devices) || []).map(d =>
(d.id === DEVICE_ID ? Object.assign({}, d, {me: true, online: true}) : Object.assign({}, d)));
if (!list.some(d => d.me)) {
list.unshift({id: DEVICE_ID, name: DEVICE_NAME, online: true, active: !!st && st.device_id === DEVICE_ID, me: true});
}
return list;
}
// What this device plays, as a report has it.
function mine() {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
if (st && (radioPlaying() || !cur)) {
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
position_ms: 0, playing: radioPlaying()};
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed())};
}
return {kind: '', position_ms: 0, playing: false};
}
function post(path, body) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify(body),
});
}
// This device plays: it is the active one now (the server's answer will say so too).
function claim() {
const st = sync.state;
if (st && st.device_id !== DEVICE_ID) {
sync.state = Object.assign({}, st, {device_id: DEVICE_ID, device_name: DEVICE_NAME, playing: true, command: null});
}
sync.hint = null;
}
// Something changed here: tell the server, a moment later so that a burst
// (switching stations, several skips) goes as one report.
function syncSoon() {
if (sync.quiet) return;
if (sync.start && mine().playing) claim();
if (!sync.timer) sync.timer = setTimeout(syncReport, 300);
}
function syncReport() {
clearTimeout(sync.timer);
sync.timer = 0;
const body = Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, mine());
const done = sync.doneCmd;
if (done) body.done_command = done;
// Only a start makes us the active device; a late 10 s report after
// another device took over doesn't take it back.
if (body.playing && sync.start) {
body.start = true;
claim();
}
sync.start = false;
sync.lastSent = Date.now();
sync.sending++;
return post('/podcasts/player/report/', body)
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(st => {
sync.sending--;
if (st) {
if (done && sync.doneCmd === done) sync.doneCmd = null;
// Only a report the server took is our last change (an ignored one isn't).
if (st.device_id === DEVICE_ID) {
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
}
heard(st);
return st;
});
}
// A state from the server (the stream, or the answer to a report or command).
// While a report of ours is on its way, states wait for its answer: they may
// be from before it, and would stop what we just started.
function heard(st) {
if (st && typeof st.seq === 'number' && (!sync.later || st.seq >= sync.later.seq)) sync.later = st;
if (busy() || !sync.later) return;
st = sync.later;
sync.later = null;
if (sync.state && st.seq < sync.state.seq) return;
sync.state = st;
sync.heardAt = Date.now();
act(st);
report();
}
// What a state means for this device.
function act(st) {
const cmd = st.command;
if (cmd && cmd.id && !sync.executed.has(cmd.id) && (cmd.to_device_id || st.device_id) === DEVICE_ID) {
sync.executed.add(cmd.id);
if (serverNow(st) - isoMs(cmd.at) > OLD_COMMAND_MS) {
sync.doneCmd = cmd.id;
syncReport();
} else {
execute(cmd, st);
}
return;
}
if (st.device_id === DEVICE_ID) {
// The server thinks we play, but we don't (this page was reloaded): say so.
if (st.playing && !mine().playing) syncSoon();
return;
}
if (!st.device_id) return;
const newer = isoMs(st.updated_at) > isoMs(sync.ownAt);
if (st.active_online) {
if (st.playing) handOver(st);
// Paused there on the episode that waits here too: ours goes to its place.
else if (newer && st.kind === 'podcast' && st.episode) movePausedTo(st.episode.id, st.position_ms);
return;
}
// The active device is gone: what it had last comes here, if it's newer than our own last change.
if (newer) adopt(st);
}
// A command for this device: carry it out, then one report that says it's done.
function execute(cmd, st) {
const kind = mine().kind;
const a = cmd.action;
sync.doneCmd = cmd.id;
sync.quiet = true;
try {
if (a === 'transfer') {
takeOver(st);
} else if (kind === 'radio') {
if (a === 'play' && !radioPlaying()) togglePlayStop();
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
} else if (kind === 'podcast') {
if (a === 'play') resume();
else if (a === 'pause' || a === 'stop') pause();
else if (a === 'seek') seek(Number(cmd.value) || 0);
else if (a === 'skip') {
const to = posMs() + (Number(cmd.value) || 0) * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
} else if (a === 'next') next();
}
} finally {
sync.quiet = false;
}
syncReport();
}
// Another device started playing: this one stops (the episode keeps its place).
function handOver(st) {
if (!wantPlay && !radioPlaying()) return;
sync.quiet = true;
try {
if (wantPlay) pause();
if (radioPlaying()) stopPlayback(false);
} finally {
sync.quiet = false;
}
toast('Läuft jetzt auf ' + (st.device_name || 'einem anderen Gerät'));
}
// Load an episode paused at a place (adopting, or before playing it here).
function loadAt(ep, at) {
at = Math.max(0, Math.round(at || 0));
if (curId() === ep.id) {
if (loaded && audio.readyState > 0) {
try { audio.currentTime = at / 1000; } catch (e) {}
} else {
startAt = at;
}
} else {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
startAt = at;
wantPlay = false;
buffering = false;
error = null;
lastDur = 0;
storePlayer();
}
mustReach = null;
pausedAt = 0;
lastPos = at;
cur.position_seconds = Math.floor(at / 1000);
forEpisode(state, ep.id, o => { o.position_seconds = Math.floor(at / 1000); });
save();
report();
}
// The episode that waits here, paused, follows a newer place from elsewhere
// (another device played on): never while it plays here, never over a place
// of ours that isn't saved yet, not for a few seconds' difference.
function movePausedTo(id, at) {
if (curId() !== id || wantPlay) return false;
if (outbox.some(op => op.key === 'progress:' + id)) return false;
if (Math.abs(at - posMs()) <= 5000) return false;
loadAt({id}, at);
return true;
}
// After fetching the podcast state: the saved place of that episode may be newer.
function followSavedPlace() {
if (!cur) return;
let secs = null;
forEpisode(state, cur.episode__id, o => { if (secs == null && o.position_seconds != null) secs = o.position_seconds; });
if (secs == null) return;
// Where resuming would start (resumeMs): from 5 s on, past the podcast's intro.
movePausedTo(cur.episode__id, Math.max(secs > 5 ? secs * 1000 : 0, introMs(curFeed())));
}
// Play what the shared player has, here, from its place ("Hier abspielen", a transfer).
function takeOver(st) {
st = st || sync.state;
if (st && st.kind === 'radio' && st.station) {
playStation(st.station.url, st.station.name || '', st.station.id || null);
} else if (st && st.kind === 'podcast' && st.episode) {
loadAt(st.episode, placeOf(st));
resume();
} else if (cur) {
resume();
}
}
// The active device is gone and we play nothing: its item becomes ours, paused.
function adopt(st) {
if (wantPlay || radioPlaying()) return;
if (st.kind === 'podcast' && st.episode) {
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
else loadAt(st.episode, st.position_ms);
} else if (st.kind === 'radio' && st.station) {
const now = typeof currentStation !== 'undefined' ? currentStation : null;
if (cur || !now || now.url !== st.station.url) {
sync.quiet = true;
try { putAway(); } finally { sync.quiet = false; }
window.__dioraAdoptStation(st.station);
}
}
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
// What a command of ours will do shows at once (the device reports within a second or so).
function hintFor(action, value) {
const st = sync.state;
const r = remoteJson();
if (!r) return;
const h = {updatedAt: st.updated_at, until: Date.now() + 5000, at: Date.now(), pos: r.pos, playing: r.playing};
if (action === 'play') h.playing = true;
else if (action === 'pause' || action === 'stop') h.playing = false;
else if (action === 'seek') h.pos = value;
else if (action === 'skip') h.pos = Math.max(0, r.pos + value * 1000);
else return;
sync.hint = h;
report();
}
// Remote control: a command for the active device (or `to`).
function command(action, value, to) {
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
if (to) body.to_device_id = to;
else hintFor(action, body.value);
return post('/podcasts/player/command/', body)
.then(r => {
if (r.ok) return r.json();
return r.json().catch(() => ({})).then(d => {
toast(d && d.error === 'nothing to transfer' ? 'Hier läuft nichts, das sich verschieben ließe'
: 'Das Gerät ist gerade nicht erreichbar');
return null;
});
})
.catch(() => { toast('Keine Verbindung zum Server'); return null; })
.then(st => {
if (!st) { sync.hint = null; report(); }
heard(st);
});
}
// Move the playback to a device: here (take over), or there (it plays from the place, we stop).
function transfer(id) {
if (id === DEVICE_ID) { takeOver(); return; }
// What we have isn't shared yet (we're the active device, or there is none): first say what it is.
(otherActive(sync.state) ? Promise.resolve() : syncReport()).then(() => command('transfer', 0, id));
}
// The stream: every change, as it happens. The server ends it every few minutes
// (and the network may drop it): then again, with the last seq we know.
let source = null;
let retryMs = 1000;
let retryTimer = 0;
function listen() {
clearTimeout(retryTimer);
if (source) source.close();
const seen = Math.max(sync.state ? sync.state.seq : -1, sync.later ? sync.later.seq : -1);
const q = new URLSearchParams({since: String(seen), device_id: DEVICE_ID, device_name: DEVICE_NAME, device_kind: 'web'});
source = new EventSource('/podcasts/player/stream/?' + q);
source.onopen = () => { retryMs = 1000; };
source.onmessage = e => heard(parse(e.data, null));
source.onerror = () => {
if (source) source.close();
source = null;
retryTimer = setTimeout(listen, retryMs);
retryMs = Math.min(retryMs * 2, 30000);
};
}
function fetchPlayer() {
return fetch('/podcasts/player/', {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(heard);
}
function startSync() {
fetchPlayer().then(listen);
}
// Leaving while playing: the others shouldn't show us as playing until we count as gone.
function syncLeaving() {
const m = mine();
if (!m.playing || !sync.state || sync.state.device_id !== DEVICE_ID || !navigator.sendBeacon) return;
m.playing = false;
navigator.sendBeacon('/podcasts/player/report/', JSON.stringify(Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, m)));
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// ---- what the page and the lock screen see ---------------------------------------------
const decoder = document.createElement('textarea');
function plain(s) {
if (!s || String(s).indexOf('&') < 0) return s || '';
decoder.innerHTML = s;
return decoder.value;
}
function playerJson() {
const fid = curFeed();
const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json(),
remote: remoteJson(), devices: devicesJson()};
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
if (!cur) { o.kind = null; return o; }
o.kind = 'podcast';
o.episodeId = cur.episode__id;
o.feedId = fid || 0;
o.title = cur.episode__title || '';
o.feed = cur.episode__feed__title || '';
o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || '';
o.pos = Math.round(posMs());
o.dur = Math.round(durMs());
o.playing = wantPlay;
o.buffering = buffering && wantPlay;
if (error) o.error = error;
return o;
}
function report() {
if (window.__dioraPlayer) window.__dioraPlayer(playerJson());
if (!('mediaSession' in navigator) || !cur || sessionFor !== cur.episode__id) return;
const ms = navigator.mediaSession;
ms.playbackState = wantPlay ? 'playing' : 'paused';
const dur = durMs() / 1000;
if (dur > 0 && ms.setPositionState) {
try {
ms.setPositionState({duration: dur, playbackRate: audio.playbackRate || 1, position: Math.min(posMs() / 1000, dur)});
} catch (e) {}
}
}
// Lock screen, notification and media keys: this episode, skip back and forward, next.
function setSession() {
if (!('mediaSession' in navigator) || !cur) return;
const ms = navigator.mediaSession;
const art = (cur.episode__artwork_url || cur.episode__feed__artwork_url || '').replace(/^http:/, 'https:');
try {
ms.metadata = new MediaMetadata({
title: plain(cur.episode__title),
artist: plain(cur.episode__feed__title),
album: plain(cur.episode__feed__title),
artwork: art ? [{src: art}] : [],
});
} catch (e) {}
const on = (name, fn) => { try { ms.setActionHandler(name, fn); } catch (e) {} };
on('play', () => resume());
on('pause', () => pause());
on('stop', () => pause());
on('seekbackward', () => skip(false));
on('seekforward', () => skip(true));
on('seekto', d => { if (d && d.seekTime != null) seek(d.seekTime * 1000); });
on('nexttrack', () => next());
on('previoustrack', null);
sessionFor = cur.episode__id;
}
let toastTimer = 0;
function toast(text) {
let t = document.getElementById('nui-radio-toast');
if (!t) {
t = document.createElement('div');
t.id = 'nui-radio-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('nui-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
}
// ---- the host API (Bridge.kt's podcast part) -------------------------------------------
const ids = json => (parse(json, []) || []).map(Number);
const sent = fn => function () { fn.apply(null, arguments); flush(); };
window.DioraPodcastHost = {
podcastState: () => JSON.stringify(state),
podcastRefresh(fetchFeeds) {
refresh(!!fetchFeeds).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
},
podcastFeedRemoved() {
refresh(false).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
},
podcastFavoriteChanged() {},
podcastEnqueue: sent((json, where, currentId) => enqueue(parse(json, []) || [], where || null, currentId > 0 ? currentId : null)),
podcastDequeue: sent(id => dequeueAll([id])),
podcastDequeueAll: sent(json => dequeueAll(ids(json))),
podcastMove: sent((id, index) => move(id, index)),
podcastReorder: sent(json => reorder(ids(json))),
podcastMarkPlayed(id, played) {
markPlayedAll([id], !!played, prefs.remove_played);
// "Unplayed" brings it back into Neu on the server; fetch that.
if (played) flush(); else refresh(false);
},
podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)),
podcastDismiss: sent(json => dismiss(ids(json))),
podcastSettings: sent(json => setSettings(parse(json, {}) || {})),
podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)),
podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})),
podcastFeedPrefs: sent((feedId, json) => feedPrefs(feedId, parse(json, {}) || {})),
podcastPrefs: () => JSON.stringify(prefsJson()),
podcastSetPref: (name, value) => setPref(name, value ? 1 : 0),
podcastSetPrefValue: (name, value) => setPref(name, Number(value)),
podcastPlay: id => playEpisode(id),
// Play an episode; one that isn't queued yet is queued first (where the setting says).
podcastPlayEpisode(json, currentId) {
const ep = parse(json, {}) || {};
const id = ep.episode__id != null ? ep.episode__id : ep.id;
if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null);
playEpisode(id);
flush();
},
podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()),
podcastSeek: ms => seek(Number(ms)),
podcastSkip: forward => skip(!!forward),
podcastSpeed(speed) {
setPref('speed', Number(speed));
applyKind();
setTimeout(report, 100);
},
podcastSpeedForPodcast(speed) {
const fid = curFeed();
if (fid) { setFeedSettings(fid, {playback_speed: speed > 0 ? Number(speed) : null}); flush(); }
applyKind();
setTimeout(report, 100);
},
playerState: () => JSON.stringify(playerJson()),
podcastSleep(minutes) {
if (minutes > 0) sleep.set(minutes, 0);
else if (minutes < 0) sleep.set(0, -minutes);
else sleep.set(0, 0);
report();
},
podcastSleepExtend(minutes) { sleep.extend(minutes); report(); },
podcastSleepAuto(on) { prefs.sleep_auto = !!on; store('prefs', prefs); },
// Player sync: control the device that plays, play it here, move it to a device.
playerRemote: (action, value) => { command(String(action), Number(value)); },
playerTakeOver: () => takeOver(),
playerTransfer: id => transfer(String(id)),
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
isEinkDevice: () => false,
// No podcastDownloads: the browser streams; the page hides everything about downloads.
};
// ---- the bottom sheet (native-ui.js's openSheet) ------------------------------------------
if (!window.DioraUI) {
let sheetEl = null;
const closeSheet = () => {
if (!sheetEl) return false;
sheetEl.remove();
sheetEl = null;
return true;
};
// items: {icon, label, hint, action, danger, active} | {custom: el} | falsy (skipped)
const openSheet = (title, items) => {
closeSheet();
const wrap = document.createElement('div');
wrap.id = 'nui-sheet';
wrap.innerHTML = '<div class="nui-backdrop"></div><div class="nui-panel" role="menu"></div>';
const panel = wrap.querySelector('.nui-panel');
if (title) {
const t = document.createElement('div');
t.className = 'nui-sheet-title';
t.textContent = title;
panel.appendChild(t);
}
for (const it of items) {
if (!it) continue;
if (it.custom) { panel.appendChild(it.custom); continue; }
const b = document.createElement('button');
b.type = 'button';
b.className = 'nui-row' + (it.danger ? ' nui-danger' : '') + (it.active ? ' nui-active' : '');
b.innerHTML = '<span class="nui-row-icon"></span><span class="nui-row-text"><span class="nui-row-label"></span></span>';
b.querySelector('.nui-row-icon').textContent = it.icon || '';
b.querySelector('.nui-row-label').textContent = it.label;
if (it.hint) {
const h = document.createElement('span');
h.className = 'nui-row-hint';
h.textContent = it.hint;
b.querySelector('.nui-row-text').appendChild(h);
}
b.addEventListener('click', () => { closeSheet(); it.action(); });
panel.appendChild(b);
}
wrap.addEventListener('click', e => {
e.stopPropagation();
if (e.target.classList.contains('nui-backdrop')) closeSheet();
});
document.body.appendChild(wrap);
sheetEl = wrap;
const first = panel.querySelector('button');
if (first) first.focus({preventScroll: true});
};
window.DioraUI = {openSheet, closeSheet};
}
// ---- page set-up -------------------------------------------------------------------------
document.documentElement.classList.add('dpw');
for (const href of UI_STYLES) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// The dock the page puts its player bar in (native-radio.js builds it in the app):
// here it is part of the radio bar at the bottom, in place of its "now playing",
// so radio and podcasts share one bar.
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
const dock = document.createElement('div');
dock.id = 'nui-dock';
const bar = document.getElementById('now-playing-bar');
if (bar) {
bar.insertBefore(dock, bar.firstChild);
document.documentElement.classList.add('dpw-in-bar');
} else {
document.body.appendChild(dock);
}
// The page keeps clear of the bar, whose height changes with the screen.
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
const placeDock = () => {
const h = bar ? bar.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty('--dpw-bar-h', Math.round(h) + 'px');
};
placeDock();
if (bar && window.ResizeObserver) new ResizeObserver(placeDock).observe(bar);
window.addEventListener('resize', placeDock);
// Escape closes what the podcast page has open: a sheet, the player, notes,
// a podcast, choosing several (its back handler) — never the tab itself.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
if (window.DioraUI.closeSheet && window.DioraUI.closeSheet()) return;
const podTab = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
const open = document.getElementById('nui-pod-player') ||
(podTab && document.querySelector('#nui-pod .nui-pod-feedhead, #nui-pod .nui-pod-selbar'));
if (open && window.__dioraBack) window.__dioraBack();
});
// The episode that was in the player before the page was left (not loaded until played).
const was = load('player', null);
if (was && was.entry && was.entry.episode__id != null) {
cur = entry(was.entry.episode__id) || was.entry;
lastPos = resumeMs();
}
// Keep the place when the page goes away; send what's pending when the connection is back.
const leaving = () => {
if (cur && loaded && audio.readyState > 0) {
saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
if (navigator.sendBeacon) {
navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify(
{episode_id: cur.episode__id, position_seconds: Math.floor(posMs() / 1000)}));
}
}
};
window.addEventListener('pagehide', leaving);
window.addEventListener('pagehide', syncLeaving);
// Back in the foreground: another device may have played meanwhile (its place, what plays).
let lastVisible = 0;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
} else if (Date.now() - lastVisible > 15000) {
lastVisible = Date.now();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
refresh(false);
fetchPlayer();
if (!source) listen();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
});
window.addEventListener('online', () => {
flush();
if (!source) listen();
});
// The shared player: what plays on the other devices, and from now on every change.
startSync();
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// The page (copied from the app) comes last, once all of the above is in place.
const script = document.createElement('script');
script.src = UI_SCRIPT;
document.body.appendChild(script);
})();