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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
2026-10-02 13:16:24 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 14:39:15 +02:00
|
|
|
|
// 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.
|
2026-10-02 14:39:15 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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;
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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;
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
// 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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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 …).
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-02 13:16:24 +02:00
|
|
|
|
// The sleep timer counts while either plays (radio and podcasts share the player).
|
|
|
|
|
|
sleep.tick(playing || radioPlaying());
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
// 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();
|
2026-10-02 13:12:59 +02:00
|
|
|
|
const r = playStation.apply(this, arguments);
|
|
|
|
|
|
radioChanged();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
sync.start = true;
|
|
|
|
|
|
syncSoon();
|
2026-10-02 13:12:59 +02:00
|
|
|
|
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
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-02 13:12:59 +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;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-02 13:12:59 +02:00
|
|
|
|
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: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +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;
|
2026-10-02 15:30:06 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 14:18:09 +02:00
|
|
|
|
// 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');
|
2026-10-02 14:18:09 +02:00
|
|
|
|
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);
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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);
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
} 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);
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
});
|
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +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);
|
|
|
|
|
|
})();
|