2312 lines
92 KiB
JavaScript
2312 lines
92 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', 'kind', 'folder',
|
||
// Folders (any number), the fetch interval and a favourite subscription (docs/library.md, docs/podcasts.md).
|
||
'folders', 'refresh_interval', 'favorite'];
|
||
// 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: [], later: [], lists_v2: false,
|
||
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;
|
||
// The Strom's marks of this visit stay until the next look (podcastStreamSeen).
|
||
if (state && state.stream_seen_before !== undefined) fresh.stream_seen_before = state.stream_seen_before;
|
||
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;
|
||
for (const o of s.later || []) if (ids.has(o.episode__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
|
||
markInQueue(s, new Set(ids), true);
|
||
const body = {episode_ids: ids, where: pos};
|
||
if (currentId) body.current_episode_id = currentId;
|
||
queueOp('/podcasts/queue/add/', body);
|
||
});
|
||
}
|
||
|
||
// ---- the Merkliste (docs/podcasts.md): state.later, entries like the queue's ------------------
|
||
|
||
function laterOf(s) {
|
||
if (!Array.isArray(s.later)) s.later = [];
|
||
return s.later;
|
||
}
|
||
|
||
function laterAdd(episodes) {
|
||
edit(s => {
|
||
const have = new Set(laterOf(s).map(e => e.episode__id));
|
||
const fresh = episodes.map(asQueueEntry).filter(e => !have.has(e.episode__id) && have.add(e.episode__id));
|
||
if (!fresh.length) return;
|
||
const queued = new Set(queueIds(s));
|
||
for (const e of fresh) {
|
||
laterOf(s).unshift(Object.assign({}, e, {added_at: new Date().toISOString(), auto: false, in_queue: queued.has(e.episode__id)}));
|
||
}
|
||
removeFromInbox(s, new Set(fresh.map(e => e.episode__id)));
|
||
queueOp('/podcasts/later/add/', {episode_ids: fresh.map(e => e.episode__id)});
|
||
});
|
||
}
|
||
|
||
function dropLater(s, set) {
|
||
if (Array.isArray(s.later)) s.later = s.later.filter(e => !set.has(e.episode__id));
|
||
}
|
||
|
||
function laterRemove(ids) {
|
||
edit(s => {
|
||
dropLater(s, new Set(ids));
|
||
queueOp('/podcasts/later/remove/', {episode_ids: ids});
|
||
});
|
||
}
|
||
|
||
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; });
|
||
const at = bookIndex(id);
|
||
if (at >= 0) { book.chapters[at].position_seconds = seconds; store('book', book); }
|
||
if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new
|
||
const body = {episode_id: id, position_seconds: seconds};
|
||
// A feed without durations (YouTube): the player's, kept by the server if it has none.
|
||
if (curId() === id && !(cur.episode__duration_seconds > 0) && lastDur > 0) body.duration_seconds = Math.round(lastDur / 1000);
|
||
queueOp('/podcasts/progress/save/', body, '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);
|
||
dropLater(s, set); // heard: off the Merkliste (the server does the same)
|
||
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);
|
||
dropLater(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];
|
||
}
|
||
// Stamm (into the Merkliste, or straight into the Wiedergabeliste) queues by itself.
|
||
if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue' || body.new_episodes === 'play';
|
||
// As the server does: the first folder is also `folder`.
|
||
if (Array.isArray(body.folders)) f.folder = body.folders[0] || '';
|
||
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
|
||
// An audiobook playing (docs/audiobooks.md): {feed_id, chapters: [entry, …]}. Its chapters
|
||
// play in order, outside the queue; playing anything else ends it. Kept across reloads.
|
||
let book = load('book', null);
|
||
if (!book || !Array.isArray(book.chapters)) book = null;
|
||
const bookIndex = id => (book ? book.chapters.findIndex(c => c.episode__id === id) : -1);
|
||
function setBook(b) {
|
||
book = b;
|
||
store('book', b);
|
||
}
|
||
|
||
// The Podcast-Radio (docs/podcasts.md): the Strom played like a station. An episode it plays
|
||
// is an entry with radio: true; the radio keeps the next ones (from /podcasts/radio/), the place
|
||
// of the one it plays and what it was done with lately — here only: nothing else learns of it
|
||
// (no progress, no lists). Playing anything else switches it off.
|
||
let podRadio = load('podradio', null); // {next: [entry, …], pos: {id, seconds}, done: [id, …]}
|
||
if (!podRadio || !Array.isArray(podRadio.next)) podRadio = null;
|
||
let radioFetchedAt = 0;
|
||
|
||
const curId = () => (cur ? cur.episode__id : null);
|
||
const curFeed = () => (cur ? cur.episode__feed__id : null);
|
||
const onRadio = () => !!(cur && cur.radio && podRadio);
|
||
|
||
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() {
|
||
if (onRadio()) {
|
||
const at = podRadio.pos && podRadio.pos.id === cur.episode__id ? podRadio.pos.seconds * 1000 : 0;
|
||
return Math.max(at > 5000 ? at : 0, introMs(curFeed()));
|
||
}
|
||
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() {
|
||
if (onRadio()) return podRadio.next[0] || null; // the radio goes on with what the server said
|
||
const inBook = bookIndex(curId());
|
||
if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter
|
||
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();
|
||
if (onRadio() && id === curId()) { // the radio's place, for the radio only
|
||
podRadio.pos = {id, seconds: Math.floor(posMs() / 1000)};
|
||
store('podradio', podRadio);
|
||
return;
|
||
}
|
||
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) {
|
||
if (onRadio() && id === curId()) { radioDone(id); return sleep.episodeFinished(); }
|
||
const at = bookIndex(id);
|
||
if (at >= 0) { book.chapters[at].played = true; store('book', book); }
|
||
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;
|
||
if (YOUTUBE.test(cur.episode__audio_url)) { prepareYouTube(); return; }
|
||
audio.src = cur.episode__audio_url;
|
||
applyKind();
|
||
}
|
||
|
||
// A YouTube episode's address is the video's page: the server never touches its sound
|
||
// (diora-web docs/youtube.md). The desktop app finds the audio-only stream on this
|
||
// computer; a browser can't (YouTube doesn't let a page read it).
|
||
const YOUTUBE = /^https:\/\/www\.youtube\.com\/watch\?v=[\w-]{11}$/;
|
||
let youtubeStale = false; // the last stream failed: find it anew
|
||
let youtubeAsk = 0;
|
||
|
||
function prepareYouTube() {
|
||
const ask = ++youtubeAsk;
|
||
audio.removeAttribute('src'); // not the stream that failed, while the new one is found
|
||
try { audio.load(); } catch (e) {}
|
||
const desk = window.dioraDesktop;
|
||
const fail = text => {
|
||
loaded = false;
|
||
error = 'youtube';
|
||
wantPlay = false;
|
||
buffering = false;
|
||
toast(text);
|
||
report();
|
||
syncSoon();
|
||
};
|
||
if (!desk || !desk.youtubeAudio) { fail('YouTube-Folgen spielen in der diora-App (Android, Desktop)'); return; }
|
||
const fresh = youtubeStale;
|
||
youtubeStale = false;
|
||
desk.youtubeAudio(cur.episode__audio_url, fresh).then(r => {
|
||
if (ask !== youtubeAsk || !loaded) return; // moved on meanwhile
|
||
if (!r || r.error || !r.url) { fail('Diese YouTube-Folge lässt sich gerade nicht abspielen'); return; }
|
||
if (r.duration > 0) lastDur = r.duration * 1000;
|
||
audio.src = r.url;
|
||
applyKind();
|
||
if (wantPlay) {
|
||
const p = audio.play();
|
||
if (p && p.catch) p.catch(() => {});
|
||
}
|
||
report();
|
||
});
|
||
}
|
||
|
||
function unload() {
|
||
loaded = false;
|
||
youtubeAsk++;
|
||
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 (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left
|
||
const fromRadio = onRadio();
|
||
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
|
||
// The radio's episode: ended or skipped, the radio is done with it; else it keeps its place.
|
||
if (fromRadio) {
|
||
if (auto || skipped || almostEnded()) radioDone(prev);
|
||
if (auto || almostEnded()) stop = sleep.episodeFinished();
|
||
else if (!skipped) saveNow(prev, false);
|
||
} else if (auto || almostEnded()) stop = finished(prev);
|
||
else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
|
||
else saveNow(prev, true);
|
||
}
|
||
unload();
|
||
if (podRadio && !e.radio) setPodRadio(null); // something else plays: the radio is off
|
||
cur = Object.assign({}, e);
|
||
if (e.radio) {
|
||
podRadio.next = podRadio.next.filter(x => x.episode__id !== e.episode__id);
|
||
store('podradio', podRadio);
|
||
radioFetch();
|
||
}
|
||
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();
|
||
if (!audio.getAttribute('src')) { report(); syncSoon(); return; } // a stream still being found
|
||
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() {
|
||
if (onRadio()) { radioAdvance(curId(), false); return; }
|
||
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) { radioCommit(); resume(); return; } // played by hand: no longer the radio's
|
||
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;
|
||
if (onRadio()) { radioAdvance(id, true); return; }
|
||
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;
|
||
if (onRadio()) { if (id !== outroDone) radioAdvance(id, true); 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;
|
||
youtubeStale = true;
|
||
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);
|
||
if (playing && onRadio() && Date.now() - radioFetchedAt > (durMs() - posMs() < 90000 ? 120000 : 600000)) radioFetch();
|
||
}
|
||
// 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);
|
||
|
||
// ---- the Podcast-Radio (docs/podcasts.md) -----------------------------------------------------
|
||
|
||
function setPodRadio(r) {
|
||
podRadio = r;
|
||
store('podradio', r);
|
||
}
|
||
|
||
const radioEntry = ep => Object.assign({}, asQueueEntry(ep), {radio: true, position_seconds: 0});
|
||
|
||
// The next ones from the server, after the episode playing (not it, not its podcast next).
|
||
function radioFetch() {
|
||
radioFetchedAt = Date.now();
|
||
const after = onRadio() ? curId() : null;
|
||
const q = new URLSearchParams({limit: '5'});
|
||
if (after) q.set('after', String(after));
|
||
// Done here lately: maybe not on the server yet (the mixing must not count them).
|
||
if (podRadio && podRadio.done && podRadio.done.length) q.set('skip', podRadio.done.slice(0, 20).join(','));
|
||
return fetch('/podcasts/radio/?' + q, {credentials: 'same-origin', cache: 'no-store'})
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.catch(() => null)
|
||
.then(d => {
|
||
if (!d || !podRadio) return null; // couldn't ask (offline): the caller keeps what it knew
|
||
const done = podRadio.done || [];
|
||
podRadio.next = (d.episodes || []).filter(e => e.id !== curId() && done.indexOf(e.id) < 0).map(radioEntry);
|
||
store('podradio', podRadio);
|
||
report();
|
||
return podRadio.next;
|
||
});
|
||
}
|
||
|
||
// Ended or skipped: the radio is done with it (kept here too, until the server has it).
|
||
function radioDone(id) {
|
||
if (!podRadio) return;
|
||
podRadio.done = [id].concat((podRadio.done || []).filter(x => x !== id)).slice(0, 50);
|
||
podRadio.next = podRadio.next.filter(x => x.episode__id !== id);
|
||
if (podRadio.pos && podRadio.pos.id === id) podRadio.pos = null;
|
||
store('podradio', podRadio);
|
||
queueOp('/podcasts/radio/heard/', {episode_id: id}, 'radio-heard:' + id);
|
||
flush();
|
||
}
|
||
|
||
// The radio moves on from `id` (ended: auto, or skipped): done with it, and what comes next is
|
||
// asked anew, so an episode that came out a minute ago goes first; offline, what it knew.
|
||
function radioAdvance(id, auto) {
|
||
const known = podRadio.next.filter(x => x.episode__id !== id)[0] || null;
|
||
const play = auto || wantPlay;
|
||
radioDone(id);
|
||
outroDone = id; // goTo doesn't count it again
|
||
if (auto && sleep.episodeFinished()) { pause(); return; }
|
||
radioFetch().then(list => {
|
||
if (curId() !== id || !onRadio()) return; // something else plays meanwhile
|
||
const n = list ? list[0] : known;
|
||
if (n) { goTo(n, false, play); return; }
|
||
pause();
|
||
toast('Podcast-Radio: gerade nichts Ungehörtes im Strom');
|
||
});
|
||
}
|
||
|
||
// 📻: the radio plays (goes on with its episode, or starts with the newest).
|
||
function radioStart() {
|
||
// Its episode, unless the radio is done with it (it ran out of episodes there).
|
||
if (onRadio() && (podRadio.done || []).indexOf(curId()) < 0) { resume(); return; }
|
||
setPodRadio({next: [], pos: null, done: (podRadio && podRadio.done) || []});
|
||
radioFetch().then(list => {
|
||
if (!podRadio) return;
|
||
if (!list) { setPodRadio(null); toast('Keine Verbindung zum Server'); return; }
|
||
if (!list.length) { setPodRadio(null); toast('Im Strom ist gerade nichts Ungehörtes'); return; }
|
||
goTo(list[0], false, true);
|
||
});
|
||
}
|
||
|
||
// "Normal weiterhören": the episode leaves the radio, first in the Wiedergabeliste at its place;
|
||
// from now on it plays as usual (with progress).
|
||
function radioCommit() {
|
||
if (!onRadio()) return;
|
||
const id = curId();
|
||
const e = Object.assign({}, cur, {position_seconds: Math.floor(posMs() / 1000)});
|
||
delete e.radio;
|
||
setPodRadio(null);
|
||
cur = e;
|
||
storePlayer();
|
||
enqueue([e], 'front', null);
|
||
saveProgress(id, e.position_seconds);
|
||
flush();
|
||
setSession();
|
||
report();
|
||
syncSoon();
|
||
}
|
||
|
||
// The radio's episode from another device (a transfer, or adopting it): the radio here.
|
||
function loadRadioAt(ep, at) {
|
||
at = Math.max(0, Math.round(at || 0));
|
||
if (!podRadio) setPodRadio({next: [], pos: null, done: []});
|
||
if (curId() !== ep.id || !cur.radio) {
|
||
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
|
||
unload();
|
||
cur = radioEntry(ep);
|
||
wantPlay = false;
|
||
buffering = false;
|
||
error = null;
|
||
lastDur = 0;
|
||
storePlayer();
|
||
} else if (loaded && audio.readyState > 0) {
|
||
try { audio.currentTime = at / 1000; } catch (e) {}
|
||
}
|
||
startAt = loaded ? null : at;
|
||
mustReach = null;
|
||
pausedAt = 0;
|
||
lastPos = at;
|
||
podRadio.pos = {id: ep.id, seconds: Math.floor(at / 1000)};
|
||
store('podradio', podRadio);
|
||
radioFetch();
|
||
report();
|
||
}
|
||
|
||
// ---- 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, podcast_radio: onRadio()};
|
||
}
|
||
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();
|
||
if (podRadio) setPodRadio(null);
|
||
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 || onRadio()) 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 && st.podcast_radio) {
|
||
loadRadioAt(st.episode, placeOf(st));
|
||
resume();
|
||
} 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 && st.podcast_radio) {
|
||
loadRadioAt(st.episode, st.position_ms);
|
||
} else 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());
|
||
const at = bookIndex(cur.episode__id);
|
||
if (at >= 0) o.book = {feed_id: book.feed_id, index: at, chapters: book.chapters.length};
|
||
if (onRadio()) {
|
||
const n = podRadio.next[0];
|
||
o.radio = {next: n ? {title: n.episode__title || '', feed: n.episode__feed__title || ''} : null};
|
||
}
|
||
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: (onRadio() ? 'Podcast-Radio · ' : '') + 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))),
|
||
// The Merkliste and the two lists (docs/podcasts.md).
|
||
podcastLaterAdd: sent(json => laterAdd(parse(json, []) || [])),
|
||
podcastLaterRemove: sent(json => laterRemove(ids(json))),
|
||
podcastListsSwitch(on) {
|
||
queueOp('/podcasts/lists/', {on: !!on});
|
||
save();
|
||
flush().then(() => refresh(false)).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
|
||
},
|
||
// The Strom was looked at: the time before (its marks) stays in the state as
|
||
// stream_seen_before; the server keeps now.
|
||
podcastStreamSeen() {
|
||
edit(s => {
|
||
s.stream_seen_before = s.stream_seen_at || null;
|
||
s.stream_seen_at = new Date().toISOString();
|
||
queueOp('/podcasts/stream/seen/', {}, 'stream-seen');
|
||
});
|
||
flush();
|
||
},
|
||
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),
|
||
// An audiobook's chapter (docs/audiobooks.md), and the rest of the book after it: not queued.
|
||
podcastPlayBook(json) {
|
||
const b = parse(json, {}) || {};
|
||
const chapters = Array.isArray(b.chapters) ? b.chapters : [];
|
||
const e = chapters[Number(b.index) || 0];
|
||
if (!e || e.episode__id == null) return;
|
||
setBook({feed_id: b.feed_id, chapters});
|
||
const at = b.position_ms != null && isFinite(Number(b.position_ms)) ? Math.max(0, Number(b.position_ms)) : null;
|
||
if (curId() === e.episode__id) {
|
||
cur = Object.assign({}, e);
|
||
storePlayer();
|
||
if (at != null) seek(at);
|
||
resume();
|
||
return;
|
||
}
|
||
goTo(e, false, false);
|
||
if (at != null) { startAt = at; lastPos = at; }
|
||
resume();
|
||
},
|
||
// 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(),
|
||
// The Podcast-Radio (docs/podcasts.md): play it, or take its episode out of it.
|
||
podcastRadio: () => radioStart(),
|
||
podcastRadioCommit: () => radioCommit(),
|
||
// + while the radio plays it: onto the Merkliste (the radio plays on).
|
||
podcastRadioLater: sent(() => { if (onRadio()) { const e = Object.assign({}, cur); delete e.radio; laterAdd([e]); } }),
|
||
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) {
|
||
const at = bookIndex(was.entry.episode__id);
|
||
cur = was.entry.radio && podRadio ? was.entry
|
||
: entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
|
||
if (cur.radio && !podRadio) delete cur.radio;
|
||
lastPos = resumeMs();
|
||
}
|
||
|
||
// Keep the place when the page goes away; send what's pending when the connection is back.
|
||
const leaving = () => {
|
||
if (onRadio()) { if (loaded && audio.readyState > 0) saveNow(cur.episode__id, false); return; }
|
||
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;
|
||
// A link to a section (#news, #audiobooks …) is where one wanted to go.
|
||
if (window.DioraNav && location.hash.length > 1) 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);
|
||
})();
|