diora-web/static/js/podcasts-web.js
mrwnslz 1533497409
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 5m17s
Web: der Sleep-Timer zählt und stoppt auch beim Radio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:16:24 +02:00

1123 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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', []);
let lastRefresh = 0;
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;
lastRefresh = Date.now();
save();
changed();
return true;
}
return false;
} catch (e) {
return false;
}
}
const queueOf = s => s.queue || (s.queue = []);
const queueIds = s => queueOf(s).map(q => q.episode__id);
function entry(id) {
return queueOf(state).find(q => q.episode__id === id) || null;
}
function feed(id) {
return (state.feeds || []).find(f => f.id === id) || null;
}
// The queue order a "keep sorted" rule gives (Podcasts.keepSortedOrder; the server sorts the same way).
function keepSortedOrder(entries, rule) {
const date = o => (o.episode__pub_date ? String(o.episode__pub_date) : null);
const left = o => (o.episode__duration_seconds || 0) - (o.position_seconds || 0);
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const byDate = desc => (a, b) => {
const da = date(a), db = date(b);
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return desc ? cmpStr(db, da) : cmpStr(da, db);
};
let cmp;
if (rule === 'new') cmp = byDate(true);
else if (rule === 'old') cmp = byDate(false);
else if (rule === 'short') cmp = (a, b) => left(a) - left(b);
else if (rule === 'long') cmp = (a, b) => left(b) - left(a);
else if (rule === 'feed') {
cmp = (a, b) => cmpStr(String(a.episode__feed__title || '').toLowerCase(), String(b.episode__feed__title || '').toLowerCase())
|| byDate(true)(a, b);
} else return entries.map(o => o.episode__id);
return entries.slice().sort(cmp).map(o => o.episode__id); // Array.sort is stable
}
function setQueueOrder(s, order) {
const byId = new Map(queueOf(s).map(q => [q.episode__id, q]));
const rule = (s.settings && s.settings.keep_sorted) || '';
const final = rule ? keepSortedOrder(order.map(id => byId.get(id)).filter(Boolean), rule) : order;
s.queue = final.map(id => byId.get(id)).filter(Boolean);
s.queue.forEach((q, i) => { q.position = i; });
}
// An inbox/feed episode as a queue entry (the server's queue/ field names).
function asQueueEntry(ep) {
if (ep.episode__id != null) return ep;
return {
episode__id: ep.id,
episode__title: ep.title || '',
episode__audio_url: ep.audio_url || '',
episode__duration_seconds: ep.duration_seconds || 0,
episode__artwork_url: ep.artwork_url || '',
episode__pub_date: ep.pub_date || null,
episode__feed__id: ep.feed__id != null ? ep.feed__id : ep.feed_id,
episode__feed__title: ep.feed__title || ep.feed_title || '',
episode__feed__artwork_url: ep.feed__artwork_url || '',
position_seconds: ep.position_seconds || 0,
};
}
function removeFromInbox(s, ids) {
s.inbox = (s.inbox || []).filter(o => !ids.has(o.id));
}
function markInQueue(s, ids, inQueue) {
for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue;
}
function forEpisode(s, id, block) {
for (const o of queueOf(s)) if (o.episode__id === id) block(o);
for (const o of s.inbox || []) if (o.id === id) block(o);
}
function enqueue(episodes, where, currentId) {
if (!episodes.length) return;
const pos = POSITIONS.indexOf(where) >= 0 ? where : (state.settings && state.settings.enqueue_position) || 'back';
edit(s => {
const order = queueIds(s);
const seen = new Set(order);
const fresh = [];
for (const e of episodes.map(asQueueEntry)) {
if (!seen.has(e.episode__id)) { seen.add(e.episode__id); fresh.push(e); }
}
if (!fresh.length) return;
for (const e of fresh) queueOf(s).push(e);
let at = order.length;
if (pos === 'front') at = 0;
else if (pos === 'after_current') {
const i = currentId ? order.indexOf(currentId) : -1;
at = i >= 0 ? i + 1 : 0;
}
const ids = fresh.map(e => e.episode__id);
setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at)));
removeFromInbox(s, new Set(ids)); // queued means no longer new
const body = {episode_ids: ids, where: pos};
if (currentId) body.current_episode_id = currentId;
queueOp('/podcasts/queue/add/', body);
});
}
function dequeueAll(ids) {
edit(s => {
const gone = new Set(ids);
setQueueOrder(s, queueIds(s).filter(id => !gone.has(id)));
markInQueue(s, gone, false);
for (const id of ids) queueOp('/podcasts/queue/remove/', {episode_id: id});
});
}
function reorder(order) {
edit(s => {
const now = queueIds(s);
const wanted = [];
for (const id of order) if (now.indexOf(id) >= 0 && wanted.indexOf(id) < 0) wanted.push(id);
const final = wanted.concat(now.filter(id => wanted.indexOf(id) < 0));
if (final.join() === now.join()) return;
setQueueOrder(s, final);
queueOp('/podcasts/queue/reorder/', {order: queueIds(s)});
});
}
function move(id, index) {
edit(s => {
const order = queueIds(s);
const at = order.indexOf(id);
if (at < 0) return;
order.splice(at, 1);
order.splice(Math.max(0, Math.min(index, order.length)), 0, id);
setQueueOrder(s, order);
queueOp('/podcasts/queue/reorder/', {order});
});
}
function saveProgress(id, seconds) {
forEpisode(state, id, o => { o.position_seconds = seconds; });
if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new
queueOp('/podcasts/progress/save/', {episode_id: id, position_seconds: seconds}, 'progress:' + id);
save();
// No re-render: progress changes every few seconds while playing.
}
// Finished or marked by hand: played and out of the inbox; out of the queue
// too unless leaveQueue is false (the user's setting).
function markPlayedAll(ids, played, leaveQueue) {
edit(s => {
for (const id of ids) queueOp('/podcasts/progress/mark-played/', {episode_id: id, played});
if (!played) return;
const set = new Set(ids);
removeFromInbox(s, set);
for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; });
if (leaveQueue) {
const inQueue = queueIds(s).filter(id => set.has(id));
if (inQueue.length) {
setQueueOrder(s, queueIds(s).filter(id => !set.has(id)));
for (const id of inQueue) queueOp('/podcasts/queue/remove/', {episode_id: id});
}
}
});
}
function dismiss(ids) {
edit(s => {
removeFromInbox(s, new Set(ids));
queueOp('/podcasts/progress/dismiss/', {episode_ids: ids});
});
}
function setSettings(changes) {
edit(s => {
const settings = s.settings || (s.settings = {});
const body = {};
for (const name of ['enqueue_position', 'auto_queue_position']) {
if (POSITIONS.indexOf(changes[name]) >= 0) settings[name] = body[name] = changes[name];
}
if ('keep_sorted' in changes && SORT_RULES.indexOf(changes.keep_sorted) >= 0) {
settings.keep_sorted = body.keep_sorted = changes.keep_sorted;
if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once
}
if (Object.keys(body).length) queueOp('/podcasts/settings/', body);
});
}
// A podcast's own settings, kept on the server so every device has them.
function setFeedSettings(feedId, changes) {
edit(s => {
const f = (s.feeds || []).find(x => x.id === feedId);
if (!f) return;
const body = {};
for (const key of Object.keys(changes)) {
if (FEED_SETTINGS.indexOf(key) < 0) continue;
f[key] = body[key] = changes[key];
}
if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue';
if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body);
});
if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); }
}
function setAutoQueue(feedId, on) {
edit(s => {
const f = (s.feeds || []).find(x => x.id === feedId);
if (f) f.auto_queue = on;
queueOp('/podcasts/feeds/' + feedId + '/set-auto-queue/', {auto_queue: on});
});
}
// ---- this device's playback settings (PlaybackPrefs.kt) --------------------------
const CHOICES = {
skip_back: [5, 10, 15, 30],
skip_forward: [10, 15, 30, 45, 60],
smart_played: [0, 15, 30, 60, 120, 300, 600],
};
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
sleep_last: 15, sleep_auto: false}, load('prefs', {}));
function setPref(name, value) {
if (name === 'speed') {
if (!(value >= 0.5 && value <= 3)) return;
prefs.speed = value;
} else if (CHOICES[name]) {
if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
prefs[name] = Math.round(value);
} else if (name === 'remove_played') {
prefs.remove_played = !!value;
} else {
return; // skip_silence, headset_next: nothing a browser can do
}
store('prefs', prefs);
}
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() {
return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward,
remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}};
}
function speedFor(feedId) {
const f = feed(feedId);
return f && f.playback_speed > 0 ? f.playback_speed : prefs.speed;
}
const hasOwnSpeed = feedId => { const f = feed(feedId); return !!(f && f.playback_speed > 0); };
const introMs = feedId => { const f = feed(feedId); return ((f && f.skip_intro) || 0) * 1000; };
const outroMs = feedId => { const f = feed(feedId); return ((f && f.skip_outro) || 0) * 1000; };
const volumeFor = feedId => { const f = feed(feedId); return f && f.volume > 0 ? f.volume : 1; };
// The page's per-podcast chips speak the app's local format: {speed, intro, outro, volume}.
function feedPrefs(feedId, changes) {
const body = {};
if ('speed' in changes) body.playback_speed = changes.speed > 0 ? changes.speed : null;
if ('intro' in changes) body.skip_intro = Math.round(changes.intro || 0);
if ('outro' in changes) body.skip_outro = Math.round(changes.outro || 0);
if ('volume' in changes) body.volume = changes.volume > 0 ? changes.volume : 1;
setFeedSettings(feedId, body);
}
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
const FADE_MS = 10000;
const MAX_GAP_MS = 10000;
const sleep = {
leftMs: 0,
episodes: 0,
lastTick: 0,
get active() { return this.leftMs > 0 || this.episodes > 0; },
set(minutes, episodes) {
this.leftMs = minutes * 60000;
this.episodes = minutes > 0 ? 0 : episodes;
this.lastTick = 0;
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
setFade(1);
},
extend(minutes) {
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
this.leftMs += minutes * 60000;
setFade(1);
},
tick(playing) {
const now = Date.now();
const delta = this.lastTick ? now - this.lastTick : 0;
this.lastTick = now;
if (this.leftMs <= 0 || !playing) return;
// A gap means nothing was ticking (paused, tab asleep): not playing time.
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
if (this.leftMs <= 0) {
this.leftMs = 0;
setFade(1);
pause();
stopRadio(); // the radio sleeps too (it plays in app.js's element)
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();
}
function applyVolume() {
// Louder than 1 needs an audio graph, which episodes from other servers can't feed: 1 at most.
audio.volume = Math.max(0, Math.min(1, Math.min(volumeFor(curFeed()), 1) * fade));
}
function applyKind() {
const rate = speedFor(curFeed());
audio.defaultPlaybackRate = rate;
if (audio.playbackRate !== rate) audio.playbackRate = rate;
audio.preservesPitch = true;
applyVolume();
}
function storePlayer() {
store('player', cur ? {entry: cur} : null);
}
// Load the episode's audio, starting where it should.
// (startAt is applied once the metadata is there, see 'loadedmetadata'.)
function prepare() {
startAt = startAt != null ? startAt : resumeMs();
lastPos = startAt;
loaded = true;
error = null;
buffering = true;
audio.src = cur.episode__audio_url;
applyKind();
}
function unload() {
loaded = false;
mustReach = null;
audio.pause();
audio.removeAttribute('src');
try { audio.load(); } catch (e) {}
}
// Move to another episode (onMediaItemTransition): the one we leave counts as
// finished when it ended (auto) or is left within its last seconds; else it keeps its place.
function goTo(e, auto, play) {
const prev = curId();
let stop = false;
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev);
else saveNow(prev, true);
}
unload();
cur = Object.assign({}, e);
startAt = null;
pausedAt = 0;
buffering = false;
error = null;
lastDur = 0;
lastPos = resumeMs();
storePlayer();
if (play && !stop) resume();
else { wantPlay = false; report(); }
}
// 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;
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();
});
}
report();
}
function pause() {
wantPlay = false;
if (!audio.paused) audio.pause();
pausedAt = pausedAt || Date.now();
report();
}
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;
report();
}
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();
}
// 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 …).
wantPlay = false;
pausedAt = pausedAt || Date.now();
if (cur) saveNow(cur.episode__id, true);
report();
});
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();
});
// Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer.
setInterval(() => {
const id = curId();
const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2;
if (id != null && loaded && audio.readyState > 0) {
lastPos = audio.currentTime * 1000;
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
}
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
if (playing || sleep.leftMs > 0) report();
}, 1000);
// ---- radio and podcasts take turns ---------------------------------------------------
function stopRadio() {
try {
if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false);
} catch (e) {}
}
// A station starts: the episode keeps its place and leaves the player (the app does the same).
function putAway() {
if (!cur) return;
if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
wantPlay = false;
unload();
cur = null;
storePlayer();
report();
if (sessionFor != null && 'mediaSession' in navigator) {
// The station sets its own handlers; these two it doesn't know about.
try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {}
try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {}
sessionFor = null;
}
}
if (typeof window.playStation === 'function') {
const playStation = window.playStation;
window.playStation = function () {
putAway();
const r = playStation.apply(this, arguments);
radioChanged();
return r;
};
}
// ---- 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;
};
}
const savedRows = document.getElementById('saved-tbody');
if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true});
// ---- what the page and the lock screen see ---------------------------------------------
const decoder = document.createElement('textarea');
function plain(s) {
if (!s || String(s).indexOf('&') < 0) return s || '';
decoder.innerHTML = s;
return decoder.value;
}
function playerJson() {
const fid = curFeed();
const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json()};
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); },
isEinkDevice: () => false,
// No podcastDownloads: the browser streams; the page hides everything about downloads.
};
// ---- the bottom sheet (native-ui.js's openSheet) ------------------------------------------
if (!window.DioraUI) {
let sheetEl = null;
const closeSheet = () => {
if (!sheetEl) return false;
sheetEl.remove();
sheetEl = null;
return true;
};
// items: {icon, label, hint, action, danger, active} | {custom: el} | falsy (skipped)
const openSheet = (title, items) => {
closeSheet();
const wrap = document.createElement('div');
wrap.id = 'nui-sheet';
wrap.innerHTML = '<div class="nui-backdrop"></div><div class="nui-panel" role="menu"></div>';
const panel = wrap.querySelector('.nui-panel');
if (title) {
const t = document.createElement('div');
t.className = 'nui-sheet-title';
t.textContent = title;
panel.appendChild(t);
}
for (const it of items) {
if (!it) continue;
if (it.custom) { panel.appendChild(it.custom); continue; }
const b = document.createElement('button');
b.type = 'button';
b.className = 'nui-row' + (it.danger ? ' nui-danger' : '') + (it.active ? ' nui-active' : '');
b.innerHTML = '<span class="nui-row-icon"></span><span class="nui-row-text"><span class="nui-row-label"></span></span>';
b.querySelector('.nui-row-icon').textContent = it.icon || '';
b.querySelector('.nui-row-label').textContent = it.label;
if (it.hint) {
const h = document.createElement('span');
h.className = 'nui-row-hint';
h.textContent = it.hint;
b.querySelector('.nui-row-text').appendChild(h);
}
b.addEventListener('click', () => { closeSheet(); it.action(); });
panel.appendChild(b);
}
wrap.addEventListener('click', e => {
e.stopPropagation();
if (e.target.classList.contains('nui-backdrop')) closeSheet();
});
document.body.appendChild(wrap);
sheetEl = wrap;
const first = panel.querySelector('button');
if (first) first.focus({preventScroll: true});
};
window.DioraUI = {openSheet, closeSheet};
}
// ---- page set-up -------------------------------------------------------------------------
document.documentElement.classList.add('dpw');
for (const href of UI_STYLES) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// The bottom dock the page puts its mini player in (native-radio.js builds it in the app).
const dock = document.createElement('div');
dock.id = 'nui-dock';
document.body.appendChild(dock);
// The dock sits right above the radio bar, whose height changes with the screen.
const bar = document.getElementById('now-playing-bar');
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);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
} else if (Date.now() - lastRefresh > 5 * 60000) {
refresh(false);
}
});
window.addEventListener('online', () => { flush(); });
// 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);
})();