diora-web/static/js/podcasts-web.js
mrwnslz e097e13a11 Podcast-Oberfläche aus diora-android 339de8f (Podcast-Seite mit ⚙, Wischgesten, Schrift, Farben, Startseite); „Neu“ zählt alle neuen Folgen, sw v66
- state/ liefert inbox_total (alle neuen Folgen) und inbox_limit neben der
  gekürzten Liste; Test dazu.
- Browser: Startseite (nui_start_page) beim Öffnen, „Rückgängig“-Leiste über
  der Radioleiste, helle Ansicht für die neuen Teile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:30:03 +02:00

1703 lines
64 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', 'auto_download', 'notify',
'episode_sort', 'episode_filter', 'skip_silence'];
// The account's playback settings (diora-web PodcastSettings) and what each may be.
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
const MIN_SPEED = 0.5;
const MAX_SPEED = 3.5;
const ACCOUNT_SETTINGS = {
skip_back: v => v === null || SKIPS.indexOf(v) >= 0,
skip_forward: v => v === null || SKIPS.indexOf(v) >= 0,
smart_played: v => v === null || (v === Math.round(v) && v >= 0 && v <= 3600),
speed_presets: v => v === null || (Array.isArray(v) && v.length > 0 && v.length <= 12 &&
v.every(x => typeof x === 'number' && x >= MIN_SPEED && x <= MAX_SPEED)),
keep_skipped: v => typeof v === 'boolean',
delete_dequeue: v => typeof v === 'boolean',
mobile_downloads: v => typeof v === 'boolean',
mobile_streaming: v => ['allow', 'ask', 'never'].indexOf(v) >= 0,
times_by_speed: v => typeof v === 'boolean',
auto_download_default: v => typeof v === 'boolean',
};
function empty() {
return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}};
}
let state = load('state', null) || empty();
let outbox = load('outbox', []);
function save() {
store('state', state);
store('outbox', outbox);
}
function changed() {
if (window.__dioraPodcasts) window.__dioraPodcasts(JSON.stringify(state));
}
function edit(block) {
block(state);
save();
changed();
}
function queueOp(path, body, key) {
if (key) outbox = outbox.filter(op => op.key !== key);
const op = {path, body};
if (key) op.key = key;
outbox.push(op);
}
let flushing = null;
// Send queued changes in order. False when the server can't be reached.
function flush() {
if (flushing) return flushing;
flushing = sendAll().then(ok => { flushing = null; return ok; }, () => { flushing = null; return false; });
return flushing;
}
async function sendAll() {
while (outbox.length) {
const op = outbox[0];
let r;
try {
r = await fetch(op.path, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify(op.body),
});
} catch (e) {
return false;
}
if (r.status === 401 || r.status >= 500) return false;
// Another 4xx: the server refuses this change (episode gone …); drop it
// rather than block everything behind it.
const i = outbox.indexOf(op);
if (i >= 0) outbox.splice(i, 1);
save();
}
return true;
}
// Fetch the state in one request (with fetchFeeds the server fetches all
// feeds first). Changes not sent yet go first; while some are still
// pending, the local copy stays.
async function refresh(fetchFeeds) {
try {
if (fetchFeeds) {
if (!(await flush())) return false;
await fetch('/podcasts/feeds/refresh-all/', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: '{"max_age":60}',
});
}
// Progress is saved every few seconds while an episode plays; try again
// when it slipped in while the state was on its way.
for (let attempt = 0; attempt < 3; attempt++) {
if (!(await flush())) return false;
const r = await fetch('/podcasts/state/?inbox_limit=200', {credentials: 'same-origin', cache: 'no-store'});
if (r.status !== 200) return false;
const fresh = await r.json();
if (outbox.length) continue;
state = fresh;
save();
changed();
followSavedPlace();
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
}
for (const name of Object.keys(ACCOUNT_SETTINGS)) {
if (!(name in changes)) continue;
let v = changes[name];
if (name === 'speed_presets' && Array.isArray(v)) {
v = v.map(x => Math.round(x * 100) / 100).filter((x, i, a) => a.indexOf(x) === i).sort((a, b) => a - b);
}
if (ACCOUNT_SETTINGS[name](v)) settings[name] = body[name] = v;
}
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: SKIPS,
skip_forward: SKIPS,
smart_played: [0, 15, 30, 45, 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', {}));
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
function eff(name) {
const v = (state.settings || {})[name];
return v != null ? v : prefs[name];
}
const keepSkipped = () => (state.settings || {}).keep_skipped !== false;
function setPref(name, value) {
if (name === 'speed') {
if (!(value >= MIN_SPEED && value <= MAX_SPEED)) return;
prefs.speed = value;
} else if (CHOICES[name]) {
if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
prefs[name] = Math.round(value);
// The account's setting, where the server has it: the same on every device.
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
} 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: eff('skip_back'), skip_forward: eff('skip_forward'),
remove_played: prefs.remove_played, smart_played: eff('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(eff('smart_played') * 1000, 1000);
}
function nextEntry() {
const q = queueOf(state);
const at = q.findIndex(e => e.episode__id === curId());
if (at >= 0) return q[at + 1] || null;
return q.length && q[0].episode__id !== curId() ? q[0] : null;
}
function saveNow(id, send) {
lastSaved = Date.now();
saveProgress(id, Math.floor(posMs() / 1000));
if (send) flush();
}
// An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
function finished(id) {
markPlayedAll([id], true, prefs.remove_played);
flush();
return sleep.episodeFinished();
}
function setFade(f) {
fade = f;
applyVolume();
}
// The bar's volume slider (app.js setVolume, 0-255): one level for radio and podcasts.
function barVolume() {
const s = document.getElementById('volume');
const v = s ? parseInt(s.value, 10) : NaN;
return isFinite(v) ? Math.max(0, Math.min(255, v)) / 255 : 1;
}
function applyVolume() {
// Louder than 1 needs an audio graph, which episodes from other servers can't feed: 1 at most.
audio.volume = Math.max(0, Math.min(1, Math.min(volumeFor(curFeed()), 1) * fade * barVolume()));
}
if (typeof window.setVolume === 'function') {
const setVolume = window.setVolume;
window.setVolume = function () {
const r = setVolume.apply(this, arguments);
applyVolume();
return r;
};
}
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; one skipped
// to the next (⏭, media keys) counts as played when skipped ones aren't kept
// (the account's keep_skipped); else it keeps its place.
function goTo(e, auto, play, skipped) {
const prev = curId();
let stop = false;
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev);
else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
else saveNow(prev, true);
}
unload();
cur = Object.assign({}, e);
startAt = null;
pausedAt = 0;
buffering = false;
error = null;
lastDur = 0;
lastPos = resumeMs();
storePlayer();
if (play && !stop) resume();
else { wantPlay = false; report(); syncSoon(); }
}
// A pause rewinds a little on the way back in, to find the thread again.
function catchUp() {
if (!pausedAt) return;
const paused = Date.now() - pausedAt;
pausedAt = 0;
mustReach = null;
const back = paused > 24 * 3600000 ? 20000 : paused > 3600000 ? 10000 : paused > 60000 ? 3000 : 800;
try { audio.currentTime = Math.max(0, audio.currentTime - back / 1000); } catch (e) {}
}
function resume() {
if (!cur) return;
stopRadio();
if (!loaded) prepare();
else catchUp();
wantPlay = true;
sync.start = true;
applyKind();
setSession();
const p = audio.play();
if (p && p.catch) {
p.catch(err => {
if (err && err.name === 'AbortError') return; // a newer load or pause came first
wantPlay = false;
buffering = false;
if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen');
report();
syncSoon();
});
}
report();
syncSoon();
}
function pause() {
wantPlay = false;
if (!audio.paused) audio.pause();
pausedAt = pausedAt || Date.now();
report();
syncSoon();
}
function seek(ms) {
if (!cur) return;
ms = Math.max(0, ms);
mustReach = null;
if (loaded && audio.readyState > 0) {
try { audio.currentTime = ms / 1000; } catch (e) {}
} else {
startAt = ms;
}
lastPos = ms;
// A place chosen while paused is kept at once (playing, it's saved every 10 s).
if (!wantPlay) saveNow(cur.episode__id, true);
report();
syncSoon();
}
function skip(forward) {
const pos = posMs();
if (!forward) { seek(pos - eff('skip_back') * 1000); return; }
const to = pos + eff('skip_forward') * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
}
// To the next episode: the one left is skipped (see goTo).
function next() {
const n = nextEntry();
if (n) goTo(n, false, wantPlay, true);
}
function playEpisode(id) {
if (curId() === id) { resume(); return; }
const e = entry(id);
if (e) goTo(e, false, true);
}
// The podcast's outro is skipped: the episode counts as finished there.
function skipOutro(id) {
if (outroDone === id) return;
const outro = outroMs(curFeed());
const dur = durMs();
if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
outroDone = id;
const n = nextEntry(); // before finished() takes this one out of the queue
if (finished(id) || !n) pause();
else goTo(n, false, true);
}
function onEnded() {
const id = curId();
if (id == null) return;
const n = nextEntry();
if (n) { goTo(n, true, true); return; }
if (id !== outroDone) finished(id);
outroDone = id;
sleep.queueEnded();
wantPlay = false;
report();
syncSoon();
}
// A browser that pauses the element while it loads (power saving) may drop
// that first seek: check it once the audio can play.
const reached = () => {
if (mustReach == null || audio.seeking) return;
if (Math.abs(audio.currentTime * 1000 - mustReach) > 3000) {
try { audio.currentTime = mustReach / 1000; } catch (e) {}
}
mustReach = null;
};
audio.addEventListener('canplay', reached);
audio.addEventListener('playing', () => {
reached();
buffering = false;
if (!wantPlay) wantPlay = true;
sleep.maybeAutoEnable();
report();
});
audio.addEventListener('waiting', () => { buffering = true; report(); });
audio.addEventListener('canplay', () => { if (buffering && audio.paused) { buffering = false; report(); } });
audio.addEventListener('pause', () => {
if (!loaded || audio.ended) return;
// Paused here, by the lock screen, or by the system (headphones out …).
const was = wantPlay;
wantPlay = false;
pausedAt = pausedAt || Date.now();
if (cur) saveNow(cur.episode__id, true);
report();
if (was) syncSoon();
});
audio.addEventListener('ended', onEnded);
audio.addEventListener('loadedmetadata', () => {
if (startAt != null) {
const at = startAt;
startAt = null;
if (at > 0) { try { audio.currentTime = at / 1000; } catch (e) {} mustReach = at; }
}
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
applyKind();
report();
});
audio.addEventListener('error', () => {
if (!loaded || !audio.getAttribute('src')) return;
error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0);
wantPlay = false;
buffering = false;
toast('Diese Folge lässt sich gerade nicht abspielen');
report();
syncSoon();
});
// Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer.
setInterval(() => {
const id = curId();
const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2;
if (id != null && loaded && audio.readyState > 0) {
lastPos = audio.currentTime * 1000;
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
}
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
// While playing, the shared player hears from us every 10 s (its place, and that we're alive).
if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport();
// Another device's place moves on in the bar too.
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
}, 1000);
// ---- radio and podcasts take turns ---------------------------------------------------
function stopRadio() {
try {
if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false);
} catch (e) {}
}
// A station starts: the episode keeps its place and leaves the player (the app does the same).
function putAway() {
if (!cur) return;
if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
wantPlay = false;
unload();
cur = null;
storePlayer();
report();
if (sessionFor != null && 'mediaSession' in navigator) {
// The station sets its own handlers; these two it doesn't know about.
try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {}
try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {}
sessionFor = null;
}
}
if (typeof window.playStation === 'function') {
const playStation = window.playStation;
window.playStation = function () {
putAway();
const r = playStation.apply(this, arguments);
radioChanged();
sync.start = true;
syncSoon();
return r;
};
}
// ---- the radio in the same player (in the app native-radio.js does this) ----------
// The podcast page's bar and full player show the station too; app.js keeps
// playing it, its own bar keeps only volume, votes, focus mode and timer.
function radioChanged() {
if (window.__dioraRadioChanged) window.__dioraRadioChanged();
}
function radioSaved(url) {
return [...document.querySelectorAll('#saved-tbody tr[data-url]')].some(r => r.dataset.url === url);
}
function radioLogo(st) {
const all = [].concat(typeof INITIAL_SAVED !== 'undefined' ? INITIAL_SAVED : [],
typeof INITIAL_FEATURED !== 'undefined' ? INITIAL_FEATURED : []);
const s = all.find(x => x && x.url === st.url);
return (s && s.favicon_url) || '';
}
const radioPlaying = () => typeof isPlaying !== 'undefined' && !!isPlaying;
window.__dioraRadioNow = function () {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
if (!st) return null;
return {
name: st.name || '',
logo: radioLogo(st),
track: typeof currentTrack !== 'undefined' ? currentTrack : '',
playing: radioPlaying(),
buffering: false,
saved: radioSaved(st.url),
};
};
// Stop keeps the station (like a paused episode): the bar stays with ▶.
window.__dioraRadioToggle = () => (radioPlaying() ? stopPlayback(false) : togglePlayStop());
window.__dioraRadioSave = () => saveCurrentStation();
for (const name of ['stopPlayback', 'updateNowPlayingUI', 'saveCurrentStation']) {
const fn = window[name];
if (typeof fn !== 'function') continue;
window[name] = function () {
const r = fn.apply(this, arguments);
if (r && typeof r.then === 'function') r.then(radioChanged, radioChanged);
else radioChanged();
return r;
};
}
// The radio stopping is news for the shared player too.
if (typeof window.stopPlayback === 'function') {
const stopPlayback = window.stopPlayback;
window.stopPlayback = function () {
const was = radioPlaying();
const r = stopPlayback.apply(this, arguments);
if (was) syncSoon();
return r;
};
}
const savedRows = document.getElementById('saved-tbody');
if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true});
// 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();
// Short, it stands in the bar ("läuft auf Chrome · Windows").
const DEVICE_NAME = browserName();
const sync = {
state: null, // the server's state, as last taken in
heardAt: 0, // when it came (this clock only measures the time since)
later: null, // the newest state that came while a report of ours was on its way
ownAt: load('sync_at', ''), // server time of our last report: what the others did before is old
sending: 0, // reports on their way
timer: 0, // a report about to go
lastSent: 0,
quiet: false, // playback changes the sync makes itself: no reports of their own
doneCmd: null, // the command the next report says is done
start: false, // playback was just started here (by the user, a command): we take over
executed: new Set(),
hint: null, // what a command of ours will change, shown until the device reports
};
const busy = () => sync.sending > 0 || !!sync.timer;
function isoMs(iso) {
const t = Date.parse(iso || '');
return isFinite(t) ? t : 0;
}
// The server's clock now: its time when the state came plus what has passed since.
function serverNow(st) {
return isoMs(st.server_time) + (Date.now() - sync.heardAt);
}
// Where the state's item is now: while it plays, its last place plus the time since.
function placeOf(st) {
let pos = st.position_ms || 0;
if (st.playing && st.active_online && st.updated_at) {
pos += Math.max(0, serverNow(st) - isoMs(st.updated_at)) * (st.speed || 1);
}
const dur = st.episode ? (st.episode.duration_seconds || 0) * 1000 : 0;
return Math.round(dur > 0 ? Math.min(pos, dur) : pos);
}
// Another device is the active one, it is online and has something.
function otherActive(st) {
return !!(st && st.device_id && st.device_id !== DEVICE_ID && st.active_online &&
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
}
// The page's player.remote: what the other device plays.
function remoteJson() {
const st = sync.state;
if (!otherActive(st)) return null;
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
const radio = st.kind === 'radio';
if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
o.title = o.station.name;
} else {
const e = st.episode;
o.episodeId = e.id;
o.feedId = e.feed__id || 0;
o.title = plain(e.title);
o.feed = plain(e.feed__title);
o.artwork = e.artwork_url || e.feed__artwork_url || '';
o.dur = (e.duration_seconds || 0) * 1000;
o.pos = placeOf(st);
}
// A command of ours shows at once, until the device reports back.
const h = sync.hint;
if (h && h.updatedAt === st.updated_at && Date.now() < h.until) {
o.playing = h.playing;
if (!radio) {
o.pos = Math.round(h.pos + (o.playing ? (Date.now() - h.at) * (st.speed || 1) : 0));
o.pos = Math.max(0, o.dur > 0 ? Math.min(o.pos, o.dur) : o.pos);
}
}
return o;
}
// The page's player.devices: the server's list, with this browser marked (and in it).
function devicesJson() {
const st = sync.state;
const list = ((st && st.devices) || []).map(d =>
(d.id === DEVICE_ID ? Object.assign({}, d, {me: true, online: true}) : Object.assign({}, d)));
if (!list.some(d => d.me)) {
list.unshift({id: DEVICE_ID, name: DEVICE_NAME, online: true, active: !!st && st.device_id === DEVICE_ID, me: true});
}
return list;
}
// What this device plays, as a report has it.
function mine() {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
if (st && (radioPlaying() || !cur)) {
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
position_ms: 0, playing: radioPlaying()};
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed())};
}
return {kind: '', position_ms: 0, playing: false};
}
function post(path, body) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify(body),
});
}
// This device plays: it is the active one now (the server's answer will say so too).
function claim() {
const st = sync.state;
if (st && st.device_id !== DEVICE_ID) {
sync.state = Object.assign({}, st, {device_id: DEVICE_ID, device_name: DEVICE_NAME, playing: true, command: null});
}
sync.hint = null;
}
// Something changed here: tell the server, a moment later so that a burst
// (switching stations, several skips) goes as one report.
function syncSoon() {
if (sync.quiet) return;
if (sync.start && mine().playing) claim();
if (!sync.timer) sync.timer = setTimeout(syncReport, 300);
}
function syncReport() {
clearTimeout(sync.timer);
sync.timer = 0;
const body = Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, mine());
const done = sync.doneCmd;
if (done) body.done_command = done;
// Only a start makes us the active device; a late 10 s report after
// another device took over doesn't take it back.
if (body.playing && sync.start) {
body.start = true;
claim();
}
sync.start = false;
sync.lastSent = Date.now();
sync.sending++;
return post('/podcasts/player/report/', body)
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(st => {
sync.sending--;
if (st) {
if (done && sync.doneCmd === done) sync.doneCmd = null;
// Only a report the server took is our last change (an ignored one isn't).
if (st.device_id === DEVICE_ID) {
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
}
heard(st);
return st;
});
}
// A state from the server (the stream, or the answer to a report or command).
// While a report of ours is on its way, states wait for its answer: they may
// be from before it, and would stop what we just started.
function heard(st) {
if (st && typeof st.seq === 'number' && (!sync.later || st.seq >= sync.later.seq)) sync.later = st;
if (busy() || !sync.later) return;
st = sync.later;
sync.later = null;
if (sync.state && st.seq < sync.state.seq) return;
sync.state = st;
sync.heardAt = Date.now();
act(st);
report();
}
// What a state means for this device.
function act(st) {
const cmd = st.command;
if (cmd && cmd.id && !sync.executed.has(cmd.id) && (cmd.to_device_id || st.device_id) === DEVICE_ID) {
sync.executed.add(cmd.id);
if (serverNow(st) - isoMs(cmd.at) > OLD_COMMAND_MS) {
sync.doneCmd = cmd.id;
syncReport();
} else {
execute(cmd, st);
}
return;
}
if (st.device_id === DEVICE_ID) {
// The server thinks we play, but we don't (this page was reloaded): say so.
if (st.playing && !mine().playing) syncSoon();
return;
}
if (!st.device_id) return;
const newer = isoMs(st.updated_at) > isoMs(sync.ownAt);
if (st.active_online) {
if (st.playing) handOver(st);
// Paused there on the episode that waits here too: ours goes to its place.
else if (newer && st.kind === 'podcast' && st.episode) movePausedTo(st.episode.id, st.position_ms);
return;
}
// The active device is gone: what it had last comes here, if it's newer than our own last change.
if (newer) adopt(st);
}
// A command for this device: carry it out, then one report that says it's done.
function execute(cmd, st) {
const kind = mine().kind;
const a = cmd.action;
sync.doneCmd = cmd.id;
sync.quiet = true;
try {
if (a === 'transfer') {
takeOver(st);
} else if (kind === 'radio') {
if (a === 'play' && !radioPlaying()) togglePlayStop();
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
} else if (kind === 'podcast') {
if (a === 'play') resume();
else if (a === 'pause' || a === 'stop') pause();
else if (a === 'seek') seek(Number(cmd.value) || 0);
else if (a === 'skip') {
const to = posMs() + (Number(cmd.value) || 0) * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
} else if (a === 'next') next();
}
} finally {
sync.quiet = false;
}
syncReport();
}
// Another device started playing: this one stops (the episode keeps its place).
function handOver(st) {
if (!wantPlay && !radioPlaying()) return;
sync.quiet = true;
try {
if (wantPlay) pause();
if (radioPlaying()) stopPlayback(false);
} finally {
sync.quiet = false;
}
toast('Läuft jetzt auf ' + (st.device_name || 'einem anderen Gerät'));
}
// Load an episode paused at a place (adopting, or before playing it here).
function loadAt(ep, at) {
at = Math.max(0, Math.round(at || 0));
if (curId() === ep.id) {
if (loaded && audio.readyState > 0) {
try { audio.currentTime = at / 1000; } catch (e) {}
} else {
startAt = at;
}
} else {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
startAt = at;
wantPlay = false;
buffering = false;
error = null;
lastDur = 0;
storePlayer();
}
mustReach = null;
pausedAt = 0;
lastPos = at;
cur.position_seconds = Math.floor(at / 1000);
forEpisode(state, ep.id, o => { o.position_seconds = Math.floor(at / 1000); });
save();
report();
}
// The episode that waits here, paused, follows a newer place from elsewhere
// (another device played on): never while it plays here, never over a place
// of ours that isn't saved yet, not for a few seconds' difference.
function movePausedTo(id, at) {
if (curId() !== id || wantPlay) return false;
if (outbox.some(op => op.key === 'progress:' + id)) return false;
if (Math.abs(at - posMs()) <= 5000) return false;
loadAt({id}, at);
return true;
}
// After fetching the podcast state: the saved place of that episode may be newer.
function followSavedPlace() {
if (!cur) return;
let secs = null;
forEpisode(state, cur.episode__id, o => { if (secs == null && o.position_seconds != null) secs = o.position_seconds; });
if (secs == null) return;
// Where resuming would start (resumeMs): from 5 s on, past the podcast's intro.
movePausedTo(cur.episode__id, Math.max(secs > 5 ? secs * 1000 : 0, introMs(curFeed())));
}
// Play what the shared player has, here, from its place ("Hier abspielen", a transfer).
function takeOver(st) {
st = st || sync.state;
if (st && st.kind === 'radio' && st.station) {
playStation(st.station.url, st.station.name || '', st.station.id || null);
} else if (st && st.kind === 'podcast' && st.episode) {
loadAt(st.episode, placeOf(st));
resume();
} else if (cur) {
resume();
}
}
// The active device is gone and we play nothing: its item becomes ours, paused.
function adopt(st) {
if (wantPlay || radioPlaying()) return;
if (st.kind === 'podcast' && st.episode) {
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
else loadAt(st.episode, st.position_ms);
} else if (st.kind === 'radio' && st.station) {
const now = typeof currentStation !== 'undefined' ? currentStation : null;
if (cur || !now || now.url !== st.station.url) {
sync.quiet = true;
try { putAway(); } finally { sync.quiet = false; }
window.__dioraAdoptStation(st.station);
}
}
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
// What a command of ours will do shows at once (the device reports within a second or so).
function hintFor(action, value) {
const st = sync.state;
const r = remoteJson();
if (!r) return;
const h = {updatedAt: st.updated_at, until: Date.now() + 5000, at: Date.now(), pos: r.pos, playing: r.playing};
if (action === 'play') h.playing = true;
else if (action === 'pause' || action === 'stop') h.playing = false;
else if (action === 'seek') h.pos = value;
else if (action === 'skip') h.pos = Math.max(0, r.pos + value * 1000);
else return;
sync.hint = h;
report();
}
// Remote control: a command for the active device (or `to`).
function command(action, value, to) {
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
if (to) body.to_device_id = to;
else hintFor(action, body.value);
return post('/podcasts/player/command/', body)
.then(r => {
if (r.ok) return r.json();
return r.json().catch(() => ({})).then(d => {
toast(d && d.error === 'nothing to transfer' ? 'Hier läuft nichts, das sich verschieben ließe'
: 'Das Gerät ist gerade nicht erreichbar');
return null;
});
})
.catch(() => { toast('Keine Verbindung zum Server'); return null; })
.then(st => {
if (!st) { sync.hint = null; report(); }
heard(st);
});
}
// Move the playback to a device: here (take over), or there (it plays from the place, we stop).
function transfer(id) {
if (id === DEVICE_ID) { takeOver(); return; }
// What we have isn't shared yet (we're the active device, or there is none): first say what it is.
(otherActive(sync.state) ? Promise.resolve() : syncReport()).then(() => command('transfer', 0, id));
}
// The stream: every change, as it happens. The server ends it every few minutes
// (and the network may drop it): then again, with the last seq we know.
let source = null;
let retryMs = 1000;
let retryTimer = 0;
function listen() {
clearTimeout(retryTimer);
if (source) source.close();
const seen = Math.max(sync.state ? sync.state.seq : -1, sync.later ? sync.later.seq : -1);
const q = new URLSearchParams({since: String(seen), device_id: DEVICE_ID, device_name: DEVICE_NAME, device_kind: 'web'});
source = new EventSource('/podcasts/player/stream/?' + q);
source.onopen = () => { retryMs = 1000; };
source.onmessage = e => heard(parse(e.data, null));
source.onerror = () => {
if (source) source.close();
source = null;
retryTimer = setTimeout(listen, retryMs);
retryMs = Math.min(retryMs * 2, 30000);
};
}
function fetchPlayer() {
return fetch('/podcasts/player/', {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(heard);
}
function startSync() {
fetchPlayer().then(listen);
}
// Leaving while playing: the others shouldn't show us as playing until we count as gone.
function syncLeaving() {
const m = mine();
if (!m.playing || !sync.state || sync.state.device_id !== DEVICE_ID || !navigator.sendBeacon) return;
m.playing = false;
navigator.sendBeacon('/podcasts/player/report/', JSON.stringify(Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, m)));
}
// ---- what the page and the lock screen see ---------------------------------------------
const decoder = document.createElement('textarea');
function plain(s) {
if (!s || String(s).indexOf('&') < 0) return s || '';
decoder.innerHTML = s;
return decoder.value;
}
function playerJson() {
const fid = curFeed();
const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json(),
remote: remoteJson(), devices: devicesJson()};
if (!cur) { o.kind = null; return o; }
o.kind = 'podcast';
o.episodeId = cur.episode__id;
o.feedId = fid || 0;
o.title = cur.episode__title || '';
o.feed = cur.episode__feed__title || '';
o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || '';
o.pos = Math.round(posMs());
o.dur = Math.round(durMs());
o.playing = wantPlay;
o.buffering = buffering && wantPlay;
if (error) o.error = error;
return o;
}
function report() {
if (window.__dioraPlayer) window.__dioraPlayer(playerJson());
if (!('mediaSession' in navigator) || !cur || sessionFor !== cur.episode__id) return;
const ms = navigator.mediaSession;
ms.playbackState = wantPlay ? 'playing' : 'paused';
const dur = durMs() / 1000;
if (dur > 0 && ms.setPositionState) {
try {
ms.setPositionState({duration: dur, playbackRate: audio.playbackRate || 1, position: Math.min(posMs() / 1000, dur)});
} catch (e) {}
}
}
// Lock screen, notification and media keys: this episode, skip back and forward, next.
function setSession() {
if (!('mediaSession' in navigator) || !cur) return;
const ms = navigator.mediaSession;
const art = (cur.episode__artwork_url || cur.episode__feed__artwork_url || '').replace(/^http:/, 'https:');
try {
ms.metadata = new MediaMetadata({
title: plain(cur.episode__title),
artist: plain(cur.episode__feed__title),
album: plain(cur.episode__feed__title),
artwork: art ? [{src: art}] : [],
});
} catch (e) {}
const on = (name, fn) => { try { ms.setActionHandler(name, fn); } catch (e) {} };
on('play', () => resume());
on('pause', () => pause());
on('stop', () => pause());
on('seekbackward', () => skip(false));
on('seekforward', () => skip(true));
on('seekto', d => { if (d && d.seekTime != null) seek(d.seekTime * 1000); });
on('nexttrack', () => next());
on('previoustrack', null);
sessionFor = cur.episode__id;
}
let toastTimer = 0;
function toast(text) {
let t = document.getElementById('nui-radio-toast');
if (!t) {
t = document.createElement('div');
t.id = 'nui-radio-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('nui-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
}
// ---- the host API (Bridge.kt's podcast part) -------------------------------------------
const ids = json => (parse(json, []) || []).map(Number);
const sent = fn => function () { fn.apply(null, arguments); flush(); };
window.DioraPodcastHost = {
podcastState: () => JSON.stringify(state),
podcastRefresh(fetchFeeds) {
refresh(!!fetchFeeds).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
},
podcastFeedRemoved() {
refresh(false).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
},
podcastFavoriteChanged() {},
podcastEnqueue: sent((json, where, currentId) => enqueue(parse(json, []) || [], where || null, currentId > 0 ? currentId : null)),
podcastDequeue: sent(id => dequeueAll([id])),
podcastDequeueAll: sent(json => dequeueAll(ids(json))),
podcastMove: sent((id, index) => move(id, index)),
podcastReorder: sent(json => reorder(ids(json))),
podcastMarkPlayed(id, played) {
markPlayedAll([id], !!played, prefs.remove_played);
// "Unplayed" brings it back into Neu on the server; fetch that.
if (played) flush(); else refresh(false);
},
podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)),
podcastDismiss: sent(json => dismiss(ids(json))),
podcastSettings: sent(json => setSettings(parse(json, {}) || {})),
podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)),
podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})),
podcastFeedPrefs: sent((feedId, json) => feedPrefs(feedId, parse(json, {}) || {})),
podcastPrefs: () => JSON.stringify(prefsJson()),
podcastSetPref: (name, value) => setPref(name, value ? 1 : 0),
podcastSetPrefValue: (name, value) => setPref(name, Number(value)),
podcastPlay: id => playEpisode(id),
// Play an episode; one that isn't queued yet is queued first (where the setting says).
podcastPlayEpisode(json, currentId) {
const ep = parse(json, {}) || {};
const id = ep.episode__id != null ? ep.episode__id : ep.id;
if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null);
playEpisode(id);
flush();
},
podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()),
podcastSeek: ms => seek(Number(ms)),
podcastSkip: forward => skip(!!forward),
podcastSpeed(speed) {
setPref('speed', Number(speed));
applyKind();
setTimeout(report, 100);
},
podcastSpeedForPodcast(speed) {
const fid = curFeed();
if (fid) { setFeedSettings(fid, {playback_speed: speed > 0 ? Number(speed) : null}); flush(); }
applyKind();
setTimeout(report, 100);
},
playerState: () => JSON.stringify(playerJson()),
podcastSleep(minutes) {
if (minutes > 0) sleep.set(minutes, 0);
else if (minutes < 0) sleep.set(0, -minutes);
else sleep.set(0, 0);
report();
},
podcastSleepExtend(minutes) { sleep.extend(minutes); report(); },
podcastSleepAuto(on) { prefs.sleep_auto = !!on; store('prefs', prefs); },
// Player sync: control the device that plays, play it here, move it to a device.
playerRemote: (action, value) => { command(String(action), Number(value)); },
playerTakeOver: () => takeOver(),
playerTransfer: id => transfer(String(id)),
isEinkDevice: () => false,
// No podcastDownloads: the browser streams; the page hides everything about downloads.
};
// ---- the bottom sheet (native-ui.js's openSheet) ------------------------------------------
if (!window.DioraUI) {
let sheetEl = null;
const closeSheet = () => {
if (!sheetEl) return false;
sheetEl.remove();
sheetEl = null;
return true;
};
// items: {icon, label, hint, action, danger, active} | {custom: el} | falsy (skipped)
const openSheet = (title, items) => {
closeSheet();
const wrap = document.createElement('div');
wrap.id = 'nui-sheet';
wrap.innerHTML = '<div class="nui-backdrop"></div><div class="nui-panel" role="menu"></div>';
const panel = wrap.querySelector('.nui-panel');
if (title) {
const t = document.createElement('div');
t.className = 'nui-sheet-title';
t.textContent = title;
panel.appendChild(t);
}
for (const it of items) {
if (!it) continue;
if (it.custom) { panel.appendChild(it.custom); continue; }
const b = document.createElement('button');
b.type = 'button';
b.className = 'nui-row' + (it.danger ? ' nui-danger' : '') + (it.active ? ' nui-active' : '');
b.innerHTML = '<span class="nui-row-icon"></span><span class="nui-row-text"><span class="nui-row-label"></span></span>';
b.querySelector('.nui-row-icon').textContent = it.icon || '';
b.querySelector('.nui-row-label').textContent = it.label;
if (it.hint) {
const h = document.createElement('span');
h.className = 'nui-row-hint';
h.textContent = it.hint;
b.querySelector('.nui-row-text').appendChild(h);
}
b.addEventListener('click', () => { closeSheet(); it.action(); });
panel.appendChild(b);
}
wrap.addEventListener('click', e => {
e.stopPropagation();
if (e.target.classList.contains('nui-backdrop')) closeSheet();
});
document.body.appendChild(wrap);
sheetEl = wrap;
const first = panel.querySelector('button');
if (first) first.focus({preventScroll: true});
};
window.DioraUI = {openSheet, closeSheet};
}
// ---- page set-up -------------------------------------------------------------------------
document.documentElement.classList.add('dpw');
for (const href of UI_STYLES) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// The dock the page puts its player bar in (native-radio.js builds it in the app):
// here it is part of the radio bar at the bottom, in place of its "now playing",
// so radio and podcasts share one bar.
const dock = document.createElement('div');
dock.id = 'nui-dock';
const bar = document.getElementById('now-playing-bar');
if (bar) {
bar.insertBefore(dock, bar.firstChild);
document.documentElement.classList.add('dpw-in-bar');
} else {
document.body.appendChild(dock);
}
// The page keeps clear of the bar, whose height changes with the screen.
const placeDock = () => {
const h = bar ? bar.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty('--dpw-bar-h', Math.round(h) + 'px');
};
placeDock();
if (bar && window.ResizeObserver) new ResizeObserver(placeDock).observe(bar);
window.addEventListener('resize', placeDock);
// Escape closes what the podcast page has open: a sheet, the player, notes,
// a podcast, choosing several (its back handler) — never the tab itself.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
if (window.DioraUI.closeSheet && window.DioraUI.closeSheet()) return;
const podTab = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
const open = document.getElementById('nui-pod-player') ||
(podTab && document.querySelector('#nui-pod .nui-pod-feedhead, #nui-pod .nui-pod-selbar'));
if (open && window.__dioraBack) window.__dioraBack();
});
// The episode that was in the player before the page was left (not loaded until played).
const was = load('player', null);
if (was && was.entry && was.entry.episode__id != null) {
cur = entry(was.entry.episode__id) || was.entry;
lastPos = resumeMs();
}
// Keep the place when the page goes away; send what's pending when the connection is back.
const leaving = () => {
if (cur && loaded && audio.readyState > 0) {
saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
if (navigator.sendBeacon) {
navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify(
{episode_id: cur.episode__id, position_seconds: Math.floor(posMs() / 1000)}));
}
}
};
window.addEventListener('pagehide', leaving);
window.addEventListener('pagehide', syncLeaving);
// Back in the foreground: another device may have played meanwhile (its place, what plays).
let lastVisible = 0;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
} else if (Date.now() - lastVisible > 15000) {
lastVisible = Date.now();
refresh(false);
fetchPlayer();
if (!source) listen();
}
});
window.addEventListener('online', () => {
flush();
if (!source) listen();
});
// The shared player: what plays on the other devices, and from now on every change.
startSync();
// The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the
// last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab.
(function openStartPage() {
let start = '';
try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {}
if (!start || typeof showTab !== 'function') return;
const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
if (nav && nav.type !== 'navigate') return;
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books';
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
})();
// 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);
})();