Die Player-Leiste sitzt in der Radio-Leiste an der Stelle von „läuft gerade“: am Desktop eine Zeile (Player, Lautstärke, Fokusmodus, Timer), am Handy zwei; die Fortschritts- bzw. Live-Linie läuft über die ganze Leiste. Der Fokusmodus zeigt den Sender weiter groß. sw v56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1130 lines
41 KiB
JavaScript
1130 lines
41 KiB
JavaScript
/**
|
||
* 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 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);
|
||
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);
|
||
})();
|