SoundCloud leitet die Feed-Adresse auf einen signierten Link um, der nach zwei Minuten abläuft. Das Audio-Element fragt für jeden weiteren Teil der Datei diesen Link an, wird abgewiesen (MEDIA_ERR_NETWORK) und blieb danach kaputt, bis die App neu startete. Jetzt wird die Folge bei einem Fehler an derselben Stelle neu geladen (bis zu dreimal pro Minute), und nach dem Aufgeben beim nächsten Abspielen frisch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1996 lines
78 KiB
JavaScript
1996 lines
78 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 window.DioraNative exists and this file does nothing. diora
|
||
* desktop has a DioraNative too (books, radio) but no podcast part of its own:
|
||
* it sets window.DIORA_PODCAST_HOST and this file is its podcast host, fitted
|
||
* into the phone layout it already shows:
|
||
* dock: 'existing' use native-radio.js's #nui-dock instead of a bar in the web radio bar
|
||
* styles: 'ui-only' only the podcast page's own CSS, not the browser layout
|
||
* radio: false native-radio.js provides the radio hooks (__dioraRadioNow …)
|
||
* deviceName, deviceKind how the shared player lists this device
|
||
* Written without ?. and ?? so old WebViews can still parse it.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
const HOST = window.DIORA_PODCAST_HOST || null;
|
||
if (window.DioraNative && !HOST) return;
|
||
if (typeof IS_AUTHENTICATED === 'undefined' || !IS_AUTHENTICATED) return;
|
||
if (!document.getElementById('tab-podcasts')) return;
|
||
const OPT = Object.assign({dock: 'bar', styles: 'web', radio: true, deviceName: '', deviceKind: 'web'}, HOST || {});
|
||
|
||
const UI_SCRIPT = '/static/podcasts/native-podcast.js';
|
||
const UI_STYLES = OPT.styles === 'ui-only' ? ['/static/podcasts/podcast-ui.css']
|
||
: ['/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;
|
||
const rr = await fetch('/podcasts/feeds/refresh-all/', {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
|
||
body: '{"max_age":60}',
|
||
});
|
||
// The server answers after ~40 s at most; when it didn't get to every feed,
|
||
// the state once more a minute later.
|
||
const done = await rr.json().then(d => d.done !== false, () => true);
|
||
if (!done) setTimeout(() => refresh(false), 60000);
|
||
}
|
||
// 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});
|
||
});
|
||
}
|
||
|
||
// "Folge löschen": out of the queue and "Neu", not played (its place stays). The
|
||
// server lists it under "discarded" so the devices with downloads delete theirs
|
||
// (the browser has none). In the player here: paused, it leaves the player (see
|
||
// dropDiscarded); playing, it goes on until it ends or another one starts.
|
||
function discard(ids) {
|
||
if (!ids.length) return;
|
||
edit(s => {
|
||
const set = new Set(ids);
|
||
const order = queueIds(s);
|
||
if (order.some(id => set.has(id))) setQueueOrder(s, order.filter(id => !set.has(id)));
|
||
markInQueue(s, set, false);
|
||
removeFromInbox(s, set);
|
||
queueOp('/podcasts/episodes/discard/', {episode_ids: ids});
|
||
});
|
||
dropDiscarded(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, sleep_from: 22, sleep_to: 6, skip_silence: 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 if (name === 'skip_silence') {
|
||
prefs.skip_silence = !!value; // the server finds the pauses, we jump over them (below)
|
||
} else if (name === 'sleep_from' || name === 'sleep_to') {
|
||
if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return;
|
||
prefs[name] = value;
|
||
} else {
|
||
return; // headset_next: nothing a browser can do
|
||
}
|
||
store('prefs', prefs);
|
||
}
|
||
|
||
// What the page shows as settings. No headset_next: the page leaves it 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: {},
|
||
skip_silence: !!prefs.skip_silence,
|
||
sleep_from: sleepHour('sleep_from', 22), sleep_to: sleepHour('sleep_to', 6)};
|
||
}
|
||
|
||
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 ----------------
|
||
// For episodes and the radio. A timer that hasn't counted for hours (set, then
|
||
// nothing played) is last night's: it's dropped (SleepRules.forgotten).
|
||
|
||
const FADE_MS = 10000;
|
||
const MAX_GAP_MS = 10000;
|
||
const IDLE_FORGET_MS = 3 * 3600000;
|
||
|
||
function sleepHour(name, fallback) {
|
||
const v = prefs[name];
|
||
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
|
||
}
|
||
|
||
// [from, to) may run over midnight; from == to: all day (SleepRules.inWindow).
|
||
function inSleepWindow(hour, from, to) {
|
||
if (from === to) return true;
|
||
return from < to ? hour >= from && hour < to : hour >= from || hour < to;
|
||
}
|
||
|
||
const sleep = {
|
||
leftMs: 0,
|
||
episodes: 0,
|
||
lastTick: 0,
|
||
lastCounted: 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;
|
||
this.lastCounted = this.active ? Date.now() : 0;
|
||
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
|
||
setFade(1);
|
||
sleepChanged();
|
||
},
|
||
extend(minutes) {
|
||
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
|
||
this.leftMs += minutes * 60000;
|
||
this.lastCounted = Date.now();
|
||
setFade(1);
|
||
sleepChanged();
|
||
},
|
||
tick(playing) {
|
||
const now = Date.now();
|
||
const delta = this.lastTick ? now - this.lastTick : 0;
|
||
this.lastTick = now;
|
||
if (!this.active) return;
|
||
if (!playing) {
|
||
if (this.lastCounted && now - this.lastCounted > IDLE_FORGET_MS) this.set(0, 0);
|
||
return;
|
||
}
|
||
this.lastCounted = now;
|
||
if (this.leftMs <= 0) 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)
|
||
if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // and Ambient
|
||
sleepChanged();
|
||
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--;
|
||
if (this.episodes === 0) sleepChanged();
|
||
return this.episodes === 0;
|
||
},
|
||
queueEnded() {
|
||
if (!this.episodes) return;
|
||
this.episodes = 0;
|
||
sleepChanged();
|
||
},
|
||
// Playback starts (an episode or the radio): in the night window, the timer may switch itself on.
|
||
maybeAutoEnable() {
|
||
if (this.active || !prefs.sleep_auto) return;
|
||
if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 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, from: sleepHour('sleep_from', 22), to: sleepHour('sleep_to', 6)};
|
||
},
|
||
};
|
||
|
||
const ambientPlaying = () => !!(window.__dioraAmbientPlaying && window.__dioraAmbientPlaying());
|
||
|
||
// Ambient starts or stops (app.js): at night the timer may switch itself on; the page shows it.
|
||
window.__dioraOnAmbient = function (on) {
|
||
if (on) sleep.maybeAutoEnable();
|
||
setTimeout(report, 100);
|
||
};
|
||
|
||
// Set, changed or done here: the other devices show it (when this one is the active one).
|
||
function sleepChanged() {
|
||
if (typeof sync !== 'undefined' && sync.state && sync.state.device_id === DEVICE_ID) syncSoon();
|
||
}
|
||
|
||
// ---- 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 (!silenceFast && audio.playbackRate !== rate) audio.playbackRate = rate; // else: after the pause
|
||
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);
|
||
}
|
||
|
||
// A discarded episode loaded here and paused leaves the player: the queue's first one
|
||
// takes its place (loaded, not playing), or nothing. Its place was kept on pause.
|
||
function dropDiscarded(ids) {
|
||
const id = curId();
|
||
if (id == null || ids.indexOf(id) < 0 || wantPlay) return;
|
||
const n = queueOf(state)[0] || null;
|
||
if (!n) { putAway(); syncSoon(); return; }
|
||
unload();
|
||
cur = Object.assign({}, n);
|
||
startAt = null;
|
||
pausedAt = 0;
|
||
buffering = false;
|
||
error = null;
|
||
lastDur = 0;
|
||
lastPos = resumeMs();
|
||
storePlayer();
|
||
report();
|
||
syncSoon();
|
||
}
|
||
|
||
function playEpisode(id) {
|
||
if (curId() === id) { resume(); return; }
|
||
const e = entry(id);
|
||
if (e) goTo(e, false, true);
|
||
}
|
||
|
||
// From `ms` on (the episode screen: a chapter, a time in the shownotes).
|
||
function playEpisodeAt(id, ms) {
|
||
if (curId() === id) { seek(ms); resume(); return; }
|
||
const e = entry(id);
|
||
if (!e) return;
|
||
goTo(e, false, false);
|
||
startAt = ms;
|
||
lastPos = ms;
|
||
resume();
|
||
}
|
||
|
||
// 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();
|
||
});
|
||
// Some hosts hand out a signed address that runs out within minutes (SoundCloud: 2).
|
||
// The element keeps asking that one for the next part of the file and gets refused:
|
||
// loaded anew from the feed's address (a fresh link) at the same place, it plays on.
|
||
// An element that failed can't play again, so after giving up it's loaded anew on play.
|
||
let failures = 0;
|
||
let failedAt = 0;
|
||
audio.addEventListener('error', () => {
|
||
if (!loaded || !audio.getAttribute('src')) return;
|
||
const at = posMs();
|
||
const play = wantPlay;
|
||
if (Date.now() - failedAt > 60000) failures = 0;
|
||
failedAt = Date.now();
|
||
loaded = false;
|
||
startAt = at;
|
||
mustReach = null;
|
||
if (play && ++failures <= 3) { resume(); return; }
|
||
error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0);
|
||
wantPlay = false;
|
||
buffering = false;
|
||
if (play) 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 any of them plays (radio and podcasts share the player; Ambient).
|
||
sleep.tick(playing || radioPlaying() || ambientPlaying());
|
||
// 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);
|
||
|
||
// ---- skip silence ----------------------------------------------------------------------
|
||
// A browser can't listen to most podcast servers' audio (no CORS), so the server works out
|
||
// once per episode which parts of its pauses go (podcasts/silence.py, the same rules as the
|
||
// app's PauseCutter: a fifth of each pause stays). It takes a little while the first time.
|
||
// The podcast's own choice, else this browser's (as in the app, PlaybackRules.skipSilence).
|
||
function skipSilenceFor(feedId) {
|
||
const f = feed(feedId);
|
||
return f && f.skip_silence != null ? !!f.skip_silence : !!prefs.skip_silence;
|
||
}
|
||
|
||
const silence = {id: null, spans: null, asked: 0};
|
||
|
||
// [[from, to], …] in seconds: what to leave out of this episode, once known.
|
||
function silenceSpans() {
|
||
const id = curId();
|
||
if (id == null || !skipSilenceFor(curFeed())) return null;
|
||
if (silence.id !== id) { silence.id = id; silence.spans = null; silence.asked = 0; }
|
||
if (!silence.spans && Date.now() - silence.asked > 20000) {
|
||
silence.asked = Date.now();
|
||
fetch(`/podcasts/episodes/${id}/silences/`, {credentials: 'same-origin', cache: 'no-store'})
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(d => { if (d && d.ready && d.v === 2 && silence.id === id) silence.spans = d.spans || []; })
|
||
.catch(() => {});
|
||
}
|
||
return silence.spans;
|
||
}
|
||
|
||
// Such a part is run through faster rather than jumped over: a jump in a streamed file waits
|
||
// for the network again (a hitch you hear), a faster rate doesn't. Only a long part is still
|
||
// jumped. Start and end are timed (timeupdate comes only every 250 ms), but not to the
|
||
// millisecond: a timer is a few ms late and currentTime moves in steps of 10–20 ms, which at
|
||
// 8× is up to 200 ms of speech. So no faster than 4× (Chromium mutes above that: a word run
|
||
// into would be gone, the room tone cut off), and back to normal early enough that the
|
||
// switch lands in the pause — per part, the rate that leaves out the most of it that way.
|
||
// A browser tab in the background gets its timers slowed to a second or more — speech run
|
||
// fast for that long would be worse than a hitch, so there it jumps.
|
||
const SILENCE_RATES = [2, 2.5, 3, 4];
|
||
const SILENCE_SLACK = 0.025; // s of real time the switch back may come late
|
||
const SILENCE_GAIN = 0.01; // s: a part that would save less is played as it is
|
||
const SILENCE_JUMP = 2.5; // s: a longer part is jumped over
|
||
let silenceFast = false;
|
||
let silenceEnd = 0; // where the part run through now is left
|
||
let silenceDone = -1; // the end of the part last run through (not entered again)
|
||
let silenceTimer = null;
|
||
|
||
function silenceNormal() {
|
||
if (!silenceFast) return;
|
||
silenceFast = false;
|
||
audio.playbackRate = speedFor(curFeed());
|
||
}
|
||
|
||
function silenceSchedule(ms) {
|
||
clearTimeout(silenceTimer);
|
||
silenceTimer = setTimeout(silenceTick, Math.max(5, Math.min(ms, 1000)));
|
||
}
|
||
|
||
// The rate for running through the rest of a part, [t, to), and where to leave it, or null.
|
||
function silencePlan(t, to, speed) {
|
||
let best = null;
|
||
for (const rate of SILENCE_RATES) {
|
||
if (rate < speed * 1.25) continue;
|
||
const end = to - rate * SILENCE_SLACK;
|
||
const gain = (end - t) * (1 / speed - 1 / rate);
|
||
if (gain >= SILENCE_GAIN && (!best || gain > best.gain)) best = {rate, end, gain};
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function silenceTick() {
|
||
silenceTimer = null;
|
||
if (!loaded || audio.paused || audio.seeking) { silenceNormal(); return; }
|
||
const spans = silenceSpans();
|
||
if (!spans) { silenceNormal(); silenceSchedule(1000); return; }
|
||
const t = audio.currentTime;
|
||
const speed = speedFor(curFeed()) || 1;
|
||
const timersFine = !document.hidden || !!window.dioraDesktop; // the desktop app never slows them
|
||
let next = null;
|
||
for (const [from, to] of spans) { // in order
|
||
if (to <= t || to === silenceDone) continue;
|
||
if (from > t) { next = from; break; }
|
||
// Inside a part that goes.
|
||
if (to - t > SILENCE_JUMP || !timersFine) {
|
||
silenceNormal();
|
||
silenceDone = to;
|
||
try { audio.currentTime = to; } catch (e) {}
|
||
silenceSchedule(50);
|
||
return;
|
||
}
|
||
if (silenceFast && t < silenceEnd) { // under way: keep the rate chosen on the way in
|
||
silenceSchedule((silenceEnd - t) / audio.playbackRate * 1000);
|
||
return;
|
||
}
|
||
const plan = silenceFast ? null : silencePlan(t, to, speed);
|
||
if (!plan) { silenceDone = to; continue; } // too little left of it: played as it is
|
||
silenceFast = true;
|
||
silenceEnd = plan.end;
|
||
audio.playbackRate = plan.rate;
|
||
silenceSchedule((plan.end - t) / plan.rate * 1000);
|
||
return;
|
||
}
|
||
silenceNormal();
|
||
if (next !== null) silenceSchedule((next - t) / (audio.playbackRate || 1) * 1000);
|
||
}
|
||
|
||
function silenceWatch() {
|
||
clearTimeout(silenceTimer);
|
||
silenceTimer = null;
|
||
if (loaded && !audio.paused && skipSilenceFor(curFeed())) silenceTick(); // stays fast inside a part
|
||
else silenceNormal();
|
||
}
|
||
audio.addEventListener('playing', silenceWatch);
|
||
audio.addEventListener('pause', silenceWatch);
|
||
audio.addEventListener('ended', silenceWatch);
|
||
audio.addEventListener('emptied', silenceWatch);
|
||
audio.addEventListener('seeked', () => { silenceDone = -1; silenceWatch(); }); // a part again
|
||
audio.addEventListener('ratechange', () => { if (!silenceFast && silenceTimer) silenceWatch(); });
|
||
// Back from the background, or the setting changed: start over.
|
||
document.addEventListener('visibilitychange', silenceWatch);
|
||
audio.addEventListener('timeupdate', () => { if (!silenceTimer && !audio.paused) silenceWatch(); });
|
||
|
||
// ---- 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);
|
||
sleep.maybeAutoEnable();
|
||
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;
|
||
|
||
// diora desktop: native-radio.js already shows the radio in the shared bar.
|
||
if (OPT.radio) {
|
||
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 ▶).
|
||
if (OPT.radio) 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 = OPT.deviceKind + '-' + 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 = OPT.deviceName || 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 other device's sleep timer now: its minutes count down while it plays (real time).
|
||
function remoteSleep(st) {
|
||
const z = st.sleep || {};
|
||
let left = z.left_ms || 0;
|
||
if (left > 0 && st.playing && st.active_online && st.updated_at) {
|
||
left = Math.max(0, left - Math.max(0, serverNow(st) - isoMs(st.updated_at)));
|
||
}
|
||
return {leftMs: Math.round(left), episodes: z.episodes || 0};
|
||
}
|
||
|
||
// 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,
|
||
sleep: remoteSleep(st)};
|
||
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 (with the sleep timer).
|
||
function mine() {
|
||
const st = typeof currentStation !== 'undefined' ? currentStation : null;
|
||
const zz = {left_ms: Math.max(0, Math.round(sleep.leftMs)), episodes: sleep.episodes};
|
||
if (st && (radioPlaying() || !cur)) {
|
||
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
|
||
position_ms: 0, playing: radioPlaying(), sleep: zz};
|
||
}
|
||
if (cur) {
|
||
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
|
||
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
|
||
}
|
||
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 (a === 'sleep') {
|
||
// minutes; -n: after n episodes; 0: off
|
||
const v = Math.round(Number(cmd.value) || 0);
|
||
sleep.set(Math.max(v, 0), v < 0 ? -v : 0);
|
||
} 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' || action === 'sleep') 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: OPT.deviceKind});
|
||
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))),
|
||
podcastDiscard: sent(json => discard(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();
|
||
},
|
||
podcastPlayEpisodeAt(json, currentId, ms) {
|
||
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);
|
||
playEpisodeAt(id, Math.max(0, Number(ms) || 0));
|
||
flush();
|
||
},
|
||
podcastNext: () => next(),
|
||
podcastToggle: () => (wantPlay ? pause() : resume()),
|
||
podcastPause: () => { if (wantPlay) pause(); },
|
||
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 -------------------------------------------------------------------------
|
||
|
||
// The browser layout (podcasts-web.css hangs off html.dpw); diora desktop keeps the phone's.
|
||
if (OPT.styles !== 'ui-only') 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.
|
||
// diora desktop: native-radio.js builds the dock (on DOMContentLoaded) with the tab bar.
|
||
if (OPT.dock !== 'existing') {
|
||
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();
|
||
}
|
||
});
|
||
// Open for long (diora desktop, a pinned tab): the state every 15 minutes too — the
|
||
// server fetches the feeds hourly, new episodes show up without a reload.
|
||
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15 * 60000);
|
||
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): Home
|
||
// unless chosen otherwise; '' = the last used tab, the web's old way. Opening diora shows
|
||
// it; reloading and back/forward keep the tab.
|
||
(function openStartPage() {
|
||
let start = null;
|
||
try { start = localStorage.getItem('nui_start_page'); } catch (e) {}
|
||
if (start === null) start = document.getElementById('tab-home') ? 'home' : '';
|
||
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'
|
||
: start === 'home' && document.getElementById('tab-home') ? 'home' : '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 —
|
||
// with an existing dock, also after the page's own DOMContentLoaded work built it.
|
||
const script = document.createElement('script');
|
||
script.src = UI_SCRIPT;
|
||
if (OPT.dock !== 'existing') document.body.appendChild(script);
|
||
else if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => setTimeout(() => document.body.appendChild(script), 0));
|
||
} else setTimeout(() => document.body.appendChild(script), 0);
|
||
})();
|