/** * Podcasts in the browser: the phone app's podcast page on top of a browser * version of what the app does in Kotlin. * * The page itself is diora-android's web-native/static/native-podcast.js, * copied unchanged to /static/podcasts/ by tools/sync_podcast_ui.py. It talks * to its host only through a small API (window.DioraNative in the app, * window.DioraPodcastHost here) and hears back through window.__dioraPodcasts, * __dioraPodcastsRefreshed and __dioraPlayer. This file is that host: * * - the state (feeds, queue, inbox, settings) from GET /podcasts/state/, * kept in localStorage for a fast, offline-capable start (Podcasts.kt); * - every change applied to that copy at once and sent to the server in * order through an outbox that survives reloads and going offline; * - a player of its own (not app.js's radio element) whose playlist is the * queue, with the app's bookkeeping (RadioService.EpisodeTracker): resume, * intro/outro, progress every 10 s and on pause, "played" near the end, * moving on by itself, rewinding a little after a pause, per-podcast * speed and volume, and the sleep timer (SleepTimer.kt); * - lock-screen and media-key controls through navigator.mediaSession; * - this device's playback settings in localStorage (PlaybackPrefs.kt). * * Radio and podcasts never play at the same time: starting an episode stops * the radio (app.js stopPlayback), starting a station puts the episode away. * * In the app window.DioraNative exists and this file does nothing. diora * desktop has a DioraNative too (books, radio) but no podcast part of its own: * it sets window.DIORA_PODCAST_HOST and this file is its podcast host, fitted * into the phone layout it already shows: * dock: 'existing' use native-radio.js's #nui-dock instead of a bar in the web radio bar * styles: 'ui-only' only the podcast page's own CSS, not the browser layout * radio: false native-radio.js provides the radio hooks (__dioraRadioNow …) * deviceName, deviceKind how the shared player lists this device * Written without ?. and ?? so old WebViews can still parse it. */ (function () { 'use strict'; const HOST = window.DIORA_PODCAST_HOST || null; if (window.DioraNative && !HOST) return; if (typeof IS_AUTHENTICATED === 'undefined' || !IS_AUTHENTICATED) return; if (!document.getElementById('tab-podcasts')) return; const OPT = Object.assign({dock: 'bar', styles: 'web', radio: true, deviceName: '', deviceKind: 'web'}, HOST || {}); const UI_SCRIPT = '/static/podcasts/native-podcast.js'; const UI_STYLES = OPT.styles === 'ui-only' ? ['/static/podcasts/podcast-ui.css'] : ['/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css']; // ---- storage ------------------------------------------------------------------ const USER = String(window.USER_ID || ''); function load(name, fallback) { try { const v = localStorage.getItem('dpw_' + name + '_' + USER); return v ? JSON.parse(v) : fallback; } catch (e) { return fallback; } } function store(name, value) { try { localStorage.setItem('dpw_' + name + '_' + USER, JSON.stringify(value)); } catch (e) {} } function parse(s, fallback) { try { return typeof s === 'string' ? JSON.parse(s) : s; } catch (e) { return fallback; } } function csrf() { return typeof getCsrfToken === 'function' ? getCsrfToken() : ''; } // ---- state and outbox (Podcasts.kt) ---------------------------------------------- const POSITIONS = ['back', 'front', 'after_current']; const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify', 'episode_sort', 'episode_filter', 'skip_silence']; // The account's playback settings (diora-web PodcastSettings) and what each may be. const SKIPS = [5, 10, 15, 20, 30, 45, 60]; const MIN_SPEED = 0.5; const MAX_SPEED = 3.5; const ACCOUNT_SETTINGS = { skip_back: v => v === null || SKIPS.indexOf(v) >= 0, skip_forward: v => v === null || SKIPS.indexOf(v) >= 0, smart_played: v => v === null || (v === Math.round(v) && v >= 0 && v <= 3600), speed_presets: v => v === null || (Array.isArray(v) && v.length > 0 && v.length <= 12 && v.every(x => typeof x === 'number' && x >= MIN_SPEED && x <= MAX_SPEED)), keep_skipped: v => typeof v === 'boolean', delete_dequeue: v => typeof v === 'boolean', mobile_downloads: v => typeof v === 'boolean', mobile_streaming: v => ['allow', 'ask', 'never'].indexOf(v) >= 0, times_by_speed: v => typeof v === 'boolean', auto_download_default: v => typeof v === 'boolean', }; function empty() { return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; } let state = load('state', null) || empty(); let outbox = load('outbox', []); function save() { store('state', state); store('outbox', outbox); } function changed() { if (window.__dioraPodcasts) window.__dioraPodcasts(JSON.stringify(state)); } function edit(block) { block(state); save(); changed(); } function queueOp(path, body, key) { if (key) outbox = outbox.filter(op => op.key !== key); const op = {path, body}; if (key) op.key = key; outbox.push(op); } let flushing = null; // Send queued changes in order. False when the server can't be reached. function flush() { if (flushing) return flushing; flushing = sendAll().then(ok => { flushing = null; return ok; }, () => { flushing = null; return false; }); return flushing; } async function sendAll() { while (outbox.length) { const op = outbox[0]; let r; try { r = await fetch(op.path, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, body: JSON.stringify(op.body), }); } catch (e) { return false; } if (r.status === 401 || r.status >= 500) return false; // Another 4xx: the server refuses this change (episode gone …); drop it // rather than block everything behind it. const i = outbox.indexOf(op); if (i >= 0) outbox.splice(i, 1); save(); } return true; } // Fetch the state in one request (with fetchFeeds the server fetches all // feeds first). Changes not sent yet go first; while some are still // pending, the local copy stays. async function refresh(fetchFeeds) { try { if (fetchFeeds) { if (!(await flush())) return false; await fetch('/podcasts/feeds/refresh-all/', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, body: '{"max_age":60}', }); } // Progress is saved every few seconds while an episode plays; try again // when it slipped in while the state was on its way. for (let attempt = 0; attempt < 3; attempt++) { if (!(await flush())) return false; const r = await fetch('/podcasts/state/?inbox_limit=200', {credentials: 'same-origin', cache: 'no-store'}); if (r.status !== 200) return false; const fresh = await r.json(); if (outbox.length) continue; state = fresh; save(); changed(); followSavedPlace(); return true; } return false; } catch (e) { return false; } } const queueOf = s => s.queue || (s.queue = []); const queueIds = s => queueOf(s).map(q => q.episode__id); function entry(id) { return queueOf(state).find(q => q.episode__id === id) || null; } function feed(id) { return (state.feeds || []).find(f => f.id === id) || null; } // The queue order a "keep sorted" rule gives (Podcasts.keepSortedOrder; the server sorts the same way). function keepSortedOrder(entries, rule) { const date = o => (o.episode__pub_date ? String(o.episode__pub_date) : null); const left = o => (o.episode__duration_seconds || 0) - (o.position_seconds || 0); const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0); const byDate = desc => (a, b) => { const da = date(a), db = date(b); if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; return desc ? cmpStr(db, da) : cmpStr(da, db); }; let cmp; if (rule === 'new') cmp = byDate(true); else if (rule === 'old') cmp = byDate(false); else if (rule === 'short') cmp = (a, b) => left(a) - left(b); else if (rule === 'long') cmp = (a, b) => left(b) - left(a); else if (rule === 'feed') { cmp = (a, b) => cmpStr(String(a.episode__feed__title || '').toLowerCase(), String(b.episode__feed__title || '').toLowerCase()) || byDate(true)(a, b); } else return entries.map(o => o.episode__id); return entries.slice().sort(cmp).map(o => o.episode__id); // Array.sort is stable } function setQueueOrder(s, order) { const byId = new Map(queueOf(s).map(q => [q.episode__id, q])); const rule = (s.settings && s.settings.keep_sorted) || ''; const final = rule ? keepSortedOrder(order.map(id => byId.get(id)).filter(Boolean), rule) : order; s.queue = final.map(id => byId.get(id)).filter(Boolean); s.queue.forEach((q, i) => { q.position = i; }); } // An inbox/feed episode as a queue entry (the server's queue/ field names). function asQueueEntry(ep) { if (ep.episode__id != null) return ep; return { episode__id: ep.id, episode__title: ep.title || '', episode__audio_url: ep.audio_url || '', episode__duration_seconds: ep.duration_seconds || 0, episode__artwork_url: ep.artwork_url || '', episode__pub_date: ep.pub_date || null, episode__feed__id: ep.feed__id != null ? ep.feed__id : ep.feed_id, episode__feed__title: ep.feed__title || ep.feed_title || '', episode__feed__artwork_url: ep.feed__artwork_url || '', position_seconds: ep.position_seconds || 0, }; } function removeFromInbox(s, ids) { s.inbox = (s.inbox || []).filter(o => !ids.has(o.id)); } function markInQueue(s, ids, inQueue) { for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue; } function forEpisode(s, id, block) { for (const o of queueOf(s)) if (o.episode__id === id) block(o); for (const o of s.inbox || []) if (o.id === id) block(o); } function enqueue(episodes, where, currentId) { if (!episodes.length) return; const pos = POSITIONS.indexOf(where) >= 0 ? where : (state.settings && state.settings.enqueue_position) || 'back'; edit(s => { const order = queueIds(s); const seen = new Set(order); const fresh = []; for (const e of episodes.map(asQueueEntry)) { if (!seen.has(e.episode__id)) { seen.add(e.episode__id); fresh.push(e); } } if (!fresh.length) return; for (const e of fresh) queueOf(s).push(e); let at = order.length; if (pos === 'front') at = 0; else if (pos === 'after_current') { const i = currentId ? order.indexOf(currentId) : -1; at = i >= 0 ? i + 1 : 0; } const ids = fresh.map(e => e.episode__id); setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at))); removeFromInbox(s, new Set(ids)); // queued means no longer new const body = {episode_ids: ids, where: pos}; if (currentId) body.current_episode_id = currentId; queueOp('/podcasts/queue/add/', body); }); } function dequeueAll(ids) { edit(s => { const gone = new Set(ids); setQueueOrder(s, queueIds(s).filter(id => !gone.has(id))); markInQueue(s, gone, false); for (const id of ids) queueOp('/podcasts/queue/remove/', {episode_id: id}); }); } function reorder(order) { edit(s => { const now = queueIds(s); const wanted = []; for (const id of order) if (now.indexOf(id) >= 0 && wanted.indexOf(id) < 0) wanted.push(id); const final = wanted.concat(now.filter(id => wanted.indexOf(id) < 0)); if (final.join() === now.join()) return; setQueueOrder(s, final); queueOp('/podcasts/queue/reorder/', {order: queueIds(s)}); }); } function move(id, index) { edit(s => { const order = queueIds(s); const at = order.indexOf(id); if (at < 0) return; order.splice(at, 1); order.splice(Math.max(0, Math.min(index, order.length)), 0, id); setQueueOrder(s, order); queueOp('/podcasts/queue/reorder/', {order}); }); } function saveProgress(id, seconds) { forEpisode(state, id, o => { o.position_seconds = seconds; }); if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new queueOp('/podcasts/progress/save/', {episode_id: id, position_seconds: seconds}, 'progress:' + id); save(); // No re-render: progress changes every few seconds while playing. } // Finished or marked by hand: played and out of the inbox; out of the queue // too unless leaveQueue is false (the user's setting). function markPlayedAll(ids, played, leaveQueue) { edit(s => { for (const id of ids) queueOp('/podcasts/progress/mark-played/', {episode_id: id, played}); if (!played) return; const set = new Set(ids); removeFromInbox(s, set); for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; }); if (leaveQueue) { const inQueue = queueIds(s).filter(id => set.has(id)); if (inQueue.length) { setQueueOrder(s, queueIds(s).filter(id => !set.has(id))); for (const id of inQueue) queueOp('/podcasts/queue/remove/', {episode_id: id}); } } }); } function dismiss(ids) { edit(s => { removeFromInbox(s, new Set(ids)); queueOp('/podcasts/progress/dismiss/', {episode_ids: ids}); }); } // "Folge löschen": out of the queue and "Neu", not played (its place stays). The // server lists it under "discarded" so the devices with downloads delete theirs // (the browser has none). In the player here: paused, it leaves the player (see // dropDiscarded); playing, it goes on until it ends or another one starts. function discard(ids) { if (!ids.length) return; edit(s => { const set = new Set(ids); const order = queueIds(s); if (order.some(id => set.has(id))) setQueueOrder(s, order.filter(id => !set.has(id))); markInQueue(s, set, false); removeFromInbox(s, set); queueOp('/podcasts/episodes/discard/', {episode_ids: ids}); }); dropDiscarded(ids); } function setSettings(changes) { edit(s => { const settings = s.settings || (s.settings = {}); const body = {}; for (const name of ['enqueue_position', 'auto_queue_position']) { if (POSITIONS.indexOf(changes[name]) >= 0) settings[name] = body[name] = changes[name]; } if ('keep_sorted' in changes && SORT_RULES.indexOf(changes.keep_sorted) >= 0) { settings.keep_sorted = body.keep_sorted = changes.keep_sorted; if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once } for (const name of Object.keys(ACCOUNT_SETTINGS)) { if (!(name in changes)) continue; let v = changes[name]; if (name === 'speed_presets' && Array.isArray(v)) { v = v.map(x => Math.round(x * 100) / 100).filter((x, i, a) => a.indexOf(x) === i).sort((a, b) => a - b); } if (ACCOUNT_SETTINGS[name](v)) settings[name] = body[name] = v; } if (Object.keys(body).length) queueOp('/podcasts/settings/', body); }); } // A podcast's own settings, kept on the server so every device has them. function setFeedSettings(feedId, changes) { edit(s => { const f = (s.feeds || []).find(x => x.id === feedId); if (!f) return; const body = {}; for (const key of Object.keys(changes)) { if (FEED_SETTINGS.indexOf(key) < 0) continue; f[key] = body[key] = changes[key]; } if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue'; if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body); }); if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); } } function setAutoQueue(feedId, on) { edit(s => { const f = (s.feeds || []).find(x => x.id === feedId); if (f) f.auto_queue = on; queueOp('/podcasts/feeds/' + feedId + '/set-auto-queue/', {auto_queue: on}); }); } // ---- this device's playback settings (PlaybackPrefs.kt) -------------------------- const CHOICES = { skip_back: SKIPS, skip_forward: SKIPS, smart_played: [0, 15, 30, 45, 60, 120, 300, 600], }; const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30, sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6}, 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 === 'sleep_from' || name === 'sleep_to') { if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return; prefs[name] = value; } else { return; // skip_silence, headset_next: nothing a browser can do } store('prefs', prefs); } // What the page shows as settings. No skip_silence / headset_next: the page leaves those out. function prefsJson() { return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'), remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}, 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) 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)}; }, }; // Set, changed or done here: the other devices show it (when this one is the active one). function sleepChanged() { if (typeof sync !== 'undefined' && sync.state && sync.state.device_id === DEVICE_ID) syncSoon(); } // ---- the player (RadioService.EpisodeTracker) --------------------------------------- const audio = new Audio(); // the radio keeps app.js's own element audio.preload = 'auto'; let cur = null; // queue entry (a copy) of the episode in the player let loaded = false; // its address is set on the audio element let wantPlay = false; // play when ready (what the page shows as playing) let startAt = null; // ms to start from when it gets loaded let lastPos = 0; // ms let lastDur = 0; // ms let lastSaved = 0; let pausedAt = 0; let outroDone = null; // episode whose outro was skipped already let fade = 1; // the sleep timer's fade-out let buffering = false; let error = null; let sessionFor = null; // episode the media session shows let mustReach = null; // ms the first seek after loading should have reached const curId = () => (cur ? cur.episode__id : null); const curFeed = () => (cur ? cur.episode__feed__id : null); function durMs() { if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000; return lastDur || ((cur && cur.episode__duration_seconds) || 0) * 1000; } function posMs() { if (mustReach != null) return mustReach; // the start place, until it's confirmed if (loaded && audio.readyState > 0) return audio.currentTime * 1000; return startAt != null ? startAt : lastPos; } // Where this episode picks up: its place (the queue knows it best), past the podcast's intro. function resumeMs() { const q = cur ? entry(cur.episode__id) : null; const resume = ((q || cur || {}).position_seconds || 0) * 1000; return Math.max(resume > 5000 ? resume : 0, introMs(curFeed())); } // Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished. function almostEnded() { const dur = durMs(); return dur > 0 && posMs() >= dur - Math.max(eff('smart_played') * 1000, 1000); } function nextEntry() { const q = queueOf(state); const at = q.findIndex(e => e.episode__id === curId()); if (at >= 0) return q[at + 1] || null; return q.length && q[0].episode__id !== curId() ? q[0] : null; } function saveNow(id, send) { lastSaved = Date.now(); saveProgress(id, Math.floor(posMs() / 1000)); if (send) flush(); } // An episode is done: played, out of the queue (setting). True when the sleep timer says stop. function finished(id) { markPlayedAll([id], true, prefs.remove_played); flush(); return sleep.episodeFinished(); } function setFade(f) { fade = f; applyVolume(); } // The bar's volume slider (app.js setVolume, 0-255): one level for radio and podcasts. function barVolume() { const s = document.getElementById('volume'); const v = s ? parseInt(s.value, 10) : NaN; return isFinite(v) ? Math.max(0, Math.min(255, v)) / 255 : 1; } function applyVolume() { // Louder than 1 needs an audio graph, which episodes from other servers can't feed: 1 at most. audio.volume = Math.max(0, Math.min(1, Math.min(volumeFor(curFeed()), 1) * fade * barVolume())); } if (typeof window.setVolume === 'function') { const setVolume = window.setVolume; window.setVolume = function () { const r = setVolume.apply(this, arguments); applyVolume(); return r; }; } function applyKind() { const rate = speedFor(curFeed()); audio.defaultPlaybackRate = rate; if (audio.playbackRate !== rate) audio.playbackRate = rate; audio.preservesPitch = true; applyVolume(); } function storePlayer() { store('player', cur ? {entry: cur} : null); } // Load the episode's audio, starting where it should. // (startAt is applied once the metadata is there, see 'loadedmetadata'.) function prepare() { startAt = startAt != null ? startAt : resumeMs(); lastPos = startAt; loaded = true; error = null; buffering = true; audio.src = cur.episode__audio_url; applyKind(); } function unload() { loaded = false; mustReach = null; audio.pause(); audio.removeAttribute('src'); try { audio.load(); } catch (e) {} } // Move to another episode (onMediaItemTransition): the one we leave counts as // finished when it ended (auto) or is left within its last seconds; one skipped // to the next (⏭, media keys) counts as played when skipped ones aren't kept // (the account's keep_skipped); else it keeps its place. function goTo(e, auto, play, skipped) { const prev = curId(); let stop = false; if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (auto || almostEnded()) stop = finished(prev); else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); } else saveNow(prev, true); } unload(); cur = Object.assign({}, e); startAt = null; pausedAt = 0; buffering = false; error = null; lastDur = 0; lastPos = resumeMs(); storePlayer(); if (play && !stop) resume(); else { wantPlay = false; report(); syncSoon(); } } // A pause rewinds a little on the way back in, to find the thread again. function catchUp() { if (!pausedAt) return; const paused = Date.now() - pausedAt; pausedAt = 0; mustReach = null; const back = paused > 24 * 3600000 ? 20000 : paused > 3600000 ? 10000 : paused > 60000 ? 3000 : 800; try { audio.currentTime = Math.max(0, audio.currentTime - back / 1000); } catch (e) {} } function resume() { if (!cur) return; stopRadio(); if (!loaded) prepare(); else catchUp(); wantPlay = true; sync.start = true; applyKind(); setSession(); const p = audio.play(); if (p && p.catch) { p.catch(err => { if (err && err.name === 'AbortError') return; // a newer load or pause came first wantPlay = false; buffering = false; if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen'); report(); syncSoon(); }); } report(); syncSoon(); } function pause() { wantPlay = false; if (!audio.paused) audio.pause(); pausedAt = pausedAt || Date.now(); report(); syncSoon(); } function seek(ms) { if (!cur) return; ms = Math.max(0, ms); mustReach = null; if (loaded && audio.readyState > 0) { try { audio.currentTime = ms / 1000; } catch (e) {} } else { startAt = ms; } lastPos = ms; // A place chosen while paused is kept at once (playing, it's saved every 10 s). if (!wantPlay) saveNow(cur.episode__id, true); report(); syncSoon(); } function skip(forward) { const pos = posMs(); if (!forward) { seek(pos - eff('skip_back') * 1000); return; } const to = pos + eff('skip_forward') * 1000; const dur = durMs(); seek(dur > 0 ? Math.min(to, dur - 1000) : to); } // To the next episode: the one left is skipped (see goTo). function next() { const n = nextEntry(); if (n) goTo(n, false, wantPlay, true); } // A discarded episode loaded here and paused leaves the player: the queue's first one // takes its place (loaded, not playing), or nothing. Its place was kept on pause. function dropDiscarded(ids) { const id = curId(); if (id == null || ids.indexOf(id) < 0 || wantPlay) return; const n = queueOf(state)[0] || null; if (!n) { putAway(); syncSoon(); return; } unload(); cur = Object.assign({}, n); startAt = null; pausedAt = 0; buffering = false; error = null; lastDur = 0; lastPos = resumeMs(); storePlayer(); report(); syncSoon(); } function playEpisode(id) { if (curId() === id) { resume(); return; } const e = entry(id); if (e) goTo(e, false, true); } // From `ms` on (the episode screen: a chapter, a time in the shownotes). function playEpisodeAt(id, ms) { if (curId() === id) { seek(ms); resume(); return; } const e = entry(id); if (!e) return; goTo(e, false, false); startAt = ms; lastPos = ms; resume(); } // The podcast's outro is skipped: the episode counts as finished there. function skipOutro(id) { if (outroDone === id) return; const outro = outroMs(curFeed()); const dur = durMs(); if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return; outroDone = id; const n = nextEntry(); // before finished() takes this one out of the queue if (finished(id) || !n) pause(); else goTo(n, false, true); } function onEnded() { const id = curId(); if (id == null) return; const n = nextEntry(); if (n) { goTo(n, true, true); return; } if (id !== outroDone) finished(id); outroDone = id; sleep.queueEnded(); wantPlay = false; report(); syncSoon(); } // A browser that pauses the element while it loads (power saving) may drop // that first seek: check it once the audio can play. const reached = () => { if (mustReach == null || audio.seeking) return; if (Math.abs(audio.currentTime * 1000 - mustReach) > 3000) { try { audio.currentTime = mustReach / 1000; } catch (e) {} } mustReach = null; }; audio.addEventListener('canplay', reached); audio.addEventListener('playing', () => { reached(); buffering = false; if (!wantPlay) wantPlay = true; sleep.maybeAutoEnable(); report(); }); audio.addEventListener('waiting', () => { buffering = true; report(); }); audio.addEventListener('canplay', () => { if (buffering && audio.paused) { buffering = false; report(); } }); audio.addEventListener('pause', () => { if (!loaded || audio.ended) return; // Paused here, by the lock screen, or by the system (headphones out …). const was = wantPlay; wantPlay = false; pausedAt = pausedAt || Date.now(); if (cur) saveNow(cur.episode__id, true); report(); if (was) syncSoon(); }); audio.addEventListener('ended', onEnded); audio.addEventListener('loadedmetadata', () => { if (startAt != null) { const at = startAt; startAt = null; if (at > 0) { try { audio.currentTime = at / 1000; } catch (e) {} mustReach = at; } } if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; applyKind(); report(); }); audio.addEventListener('error', () => { if (!loaded || !audio.getAttribute('src')) return; error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0); wantPlay = false; buffering = false; toast('Diese Folge lässt sich gerade nicht abspielen'); report(); syncSoon(); }); // Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer. setInterval(() => { const id = curId(); const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2; if (id != null && loaded && audio.readyState > 0) { lastPos = audio.currentTime * 1000; if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; if (playing && Date.now() - lastSaved > 10000) saveNow(id, true); if (playing) skipOutro(id); } // The sleep timer counts while either plays (radio and podcasts share the player). sleep.tick(playing || radioPlaying()); // While playing, the shared player hears from us every 10 s (its place, and that we're alive). if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport(); // Another device's place moves on in the bar too. if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report(); }, 1000); // ---- radio and podcasts take turns --------------------------------------------------- function stopRadio() { try { if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false); } catch (e) {} } // A station starts: the episode keeps its place and leaves the player (the app does the same). function putAway() { if (!cur) return; if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true); wantPlay = false; unload(); cur = null; storePlayer(); report(); if (sessionFor != null && 'mediaSession' in navigator) { // The station sets its own handlers; these two it doesn't know about. try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {} try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {} sessionFor = null; } } if (typeof window.playStation === 'function') { const playStation = window.playStation; window.playStation = function () { putAway(); const r = playStation.apply(this, arguments); sleep.maybeAutoEnable(); radioChanged(); sync.start = true; syncSoon(); return r; }; } // ---- the radio in the same player (in the app native-radio.js does this) ---------- // The podcast page's bar and full player show the station too; app.js keeps // playing it, its own bar keeps only volume, votes, focus mode and timer. function radioChanged() { if (window.__dioraRadioChanged) window.__dioraRadioChanged(); } function radioSaved(url) { return [...document.querySelectorAll('#saved-tbody tr[data-url]')].some(r => r.dataset.url === url); } function radioLogo(st) { const all = [].concat(typeof INITIAL_SAVED !== 'undefined' ? INITIAL_SAVED : [], typeof INITIAL_FEATURED !== 'undefined' ? INITIAL_FEATURED : []); const s = all.find(x => x && x.url === st.url); return (s && s.favicon_url) || ''; } const radioPlaying = () => typeof isPlaying !== 'undefined' && !!isPlaying; // diora desktop: native-radio.js already shows the radio in the shared bar. if (OPT.radio) { window.__dioraRadioNow = function () { const st = typeof currentStation !== 'undefined' ? currentStation : null; if (!st) return null; return { name: st.name || '', logo: radioLogo(st), track: typeof currentTrack !== 'undefined' ? currentTrack : '', playing: radioPlaying(), buffering: false, saved: radioSaved(st.url), }; }; // Stop keeps the station (like a paused episode): the bar stays with ▶. window.__dioraRadioToggle = () => (radioPlaying() ? stopPlayback(false) : togglePlayStop()); window.__dioraRadioSave = () => saveCurrentStation(); for (const name of ['stopPlayback', 'updateNowPlayingUI', 'saveCurrentStation']) { const fn = window[name]; if (typeof fn !== 'function') continue; window[name] = function () { const r = fn.apply(this, arguments); if (r && typeof r.then === 'function') r.then(radioChanged, radioChanged); else radioChanged(); return r; }; } } // The radio stopping is news for the shared player too. if (typeof window.stopPlayback === 'function') { const stopPlayback = window.stopPlayback; window.stopPlayback = function () { const was = radioPlaying(); const r = stopPlayback.apply(this, arguments); if (was) syncSoon(); return r; }; } const savedRows = document.getElementById('saved-tbody'); if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true}); // Player sync: the station another device played last becomes the current // one here, without playing (the bar shows it with ▶). if (OPT.radio) window.__dioraAdoptStation = function (st) { if (!st || !st.url || radioPlaying() || typeof currentStation === 'undefined') return; currentStation = {url: st.url, name: st.name || '', id: st.id || null}; currentTrack = ''; const name = document.getElementById('now-playing-station'); if (name) name.textContent = currentStation.name; const track = document.getElementById('now-playing-track'); if (track) track.textContent = ''; const b = document.getElementById('play-stop-btn'); if (b) { b.style.display = ''; b.textContent = '▶ Play'; b.classList.remove('playing'); } radioChanged(); }; // ---- player sync: one player for all devices (like Spotify Connect) ------------------ // The server keeps what plays (/podcasts/player/, see podcasts/views.py). The // device that starts playing becomes the active one and stays it while it is // online, also paused. Every other device stops its own playback and shows // the active one's as "remote" (playerJson): its buttons send commands // there, "Hier abspielen" plays it here from its place, the device sheet // moves it anywhere (transfer). When the active device is gone, what it // played last is loaded here, paused. In the app PlayerSync.kt does this. const REPORT_EVERY_MS = 10000; const OLD_COMMAND_MS = 60000; // a command this old (the page was away) is only acknowledged function deviceId() { let id = ''; try { id = localStorage.getItem('diora_device_id') || ''; } catch (e) {} if (!id) { id = OPT.deviceKind + '-' + Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join(''); try { localStorage.setItem('diora_device_id', id); } catch (e) {} } return id; } function browserName() { const ua = navigator.userAgent; const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web'; const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : ''; return os ? browser + ' · ' + os : browser; } const DEVICE_ID = deviceId(); // Short, it stands in the bar ("läuft auf Chrome · Windows"). const DEVICE_NAME = OPT.deviceName || browserName(); const sync = { state: null, // the server's state, as last taken in heardAt: 0, // when it came (this clock only measures the time since) later: null, // the newest state that came while a report of ours was on its way ownAt: load('sync_at', ''), // server time of our last report: what the others did before is old sending: 0, // reports on their way timer: 0, // a report about to go lastSent: 0, quiet: false, // playback changes the sync makes itself: no reports of their own doneCmd: null, // the command the next report says is done start: false, // playback was just started here (by the user, a command): we take over executed: new Set(), hint: null, // what a command of ours will change, shown until the device reports }; const busy = () => sync.sending > 0 || !!sync.timer; function isoMs(iso) { const t = Date.parse(iso || ''); return isFinite(t) ? t : 0; } // The server's clock now: its time when the state came plus what has passed since. function serverNow(st) { return isoMs(st.server_time) + (Date.now() - sync.heardAt); } // Where the state's item is now: while it plays, its last place plus the time since. function placeOf(st) { let pos = st.position_ms || 0; if (st.playing && st.active_online && st.updated_at) { pos += Math.max(0, serverNow(st) - isoMs(st.updated_at)) * (st.speed || 1); } const dur = st.episode ? (st.episode.duration_seconds || 0) * 1000 : 0; return Math.round(dur > 0 ? Math.min(pos, dur) : pos); } // Another device is the active one, it is online and has something. function otherActive(st) { return !!(st && st.device_id && st.device_id !== DEVICE_ID && st.active_online && ((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station))); } // The other device's sleep timer now: its minutes count down while it plays (real time). function remoteSleep(st) { const z = st.sleep || {}; let left = z.left_ms || 0; if (left > 0 && st.playing && st.active_online && st.updated_at) { left = Math.max(0, left - Math.max(0, serverNow(st) - isoMs(st.updated_at))); } return {leftMs: Math.round(left), episodes: z.episodes || 0}; } // The page's player.remote: what the other device plays. function remoteJson() { const st = sync.state; if (!otherActive(st)) return null; const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0, sleep: remoteSleep(st)}; const radio = st.kind === 'radio'; if (radio) { o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)}; o.title = o.station.name; } else { const e = st.episode; o.episodeId = e.id; o.feedId = e.feed__id || 0; o.title = plain(e.title); o.feed = plain(e.feed__title); o.artwork = e.artwork_url || e.feed__artwork_url || ''; o.dur = (e.duration_seconds || 0) * 1000; o.pos = placeOf(st); } // A command of ours shows at once, until the device reports back. const h = sync.hint; if (h && h.updatedAt === st.updated_at && Date.now() < h.until) { o.playing = h.playing; if (!radio) { o.pos = Math.round(h.pos + (o.playing ? (Date.now() - h.at) * (st.speed || 1) : 0)); o.pos = Math.max(0, o.dur > 0 ? Math.min(o.pos, o.dur) : o.pos); } } return o; } // The page's player.devices: the server's list, with this browser marked (and in it). function devicesJson() { const st = sync.state; const list = ((st && st.devices) || []).map(d => (d.id === DEVICE_ID ? Object.assign({}, d, {me: true, online: true}) : Object.assign({}, d))); if (!list.some(d => d.me)) { list.unshift({id: DEVICE_ID, name: DEVICE_NAME, online: true, active: !!st && st.device_id === DEVICE_ID, me: true}); } return list; } // What this device plays, as a report has it (with the sleep timer). function mine() { const st = typeof currentStation !== 'undefined' ? currentStation : null; const zz = {left_ms: Math.max(0, Math.round(sleep.leftMs)), episodes: sleep.episodes}; if (st && (radioPlaying() || !cur)) { return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null}, position_ms: 0, playing: radioPlaying(), sleep: zz}; } if (cur) { return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())), playing: wantPlay, speed: speedFor(curFeed()), sleep: zz}; } return {kind: '', position_ms: 0, playing: false}; } function post(path, body) { return fetch(path, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, body: JSON.stringify(body), }); } // This device plays: it is the active one now (the server's answer will say so too). function claim() { const st = sync.state; if (st && st.device_id !== DEVICE_ID) { sync.state = Object.assign({}, st, {device_id: DEVICE_ID, device_name: DEVICE_NAME, playing: true, command: null}); } sync.hint = null; } // Something changed here: tell the server, a moment later so that a burst // (switching stations, several skips) goes as one report. function syncSoon() { if (sync.quiet) return; if (sync.start && mine().playing) claim(); if (!sync.timer) sync.timer = setTimeout(syncReport, 300); } function syncReport() { clearTimeout(sync.timer); sync.timer = 0; const body = Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, mine()); const done = sync.doneCmd; if (done) body.done_command = done; // Only a start makes us the active device; a late 10 s report after // another device took over doesn't take it back. if (body.playing && sync.start) { body.start = true; claim(); } sync.start = false; sync.lastSent = Date.now(); sync.sending++; return post('/podcasts/player/report/', body) .then(r => (r.ok ? r.json() : null)) .catch(() => null) .then(st => { sync.sending--; if (st) { if (done && sync.doneCmd === done) sync.doneCmd = null; // Only a report the server took is our last change (an ignored one isn't). if (st.device_id === DEVICE_ID) { sync.ownAt = st.server_time; store('sync_at', sync.ownAt); } } heard(st); return st; }); } // A state from the server (the stream, or the answer to a report or command). // While a report of ours is on its way, states wait for its answer: they may // be from before it, and would stop what we just started. function heard(st) { if (st && typeof st.seq === 'number' && (!sync.later || st.seq >= sync.later.seq)) sync.later = st; if (busy() || !sync.later) return; st = sync.later; sync.later = null; if (sync.state && st.seq < sync.state.seq) return; sync.state = st; sync.heardAt = Date.now(); act(st); report(); } // What a state means for this device. function act(st) { const cmd = st.command; if (cmd && cmd.id && !sync.executed.has(cmd.id) && (cmd.to_device_id || st.device_id) === DEVICE_ID) { sync.executed.add(cmd.id); if (serverNow(st) - isoMs(cmd.at) > OLD_COMMAND_MS) { sync.doneCmd = cmd.id; syncReport(); } else { execute(cmd, st); } return; } if (st.device_id === DEVICE_ID) { // The server thinks we play, but we don't (this page was reloaded): say so. if (st.playing && !mine().playing) syncSoon(); return; } if (!st.device_id) return; const newer = isoMs(st.updated_at) > isoMs(sync.ownAt); if (st.active_online) { if (st.playing) handOver(st); // Paused there on the episode that waits here too: ours goes to its place. else if (newer && st.kind === 'podcast' && st.episode) movePausedTo(st.episode.id, st.position_ms); return; } // The active device is gone: what it had last comes here, if it's newer than our own last change. if (newer) adopt(st); } // A command for this device: carry it out, then one report that says it's done. function execute(cmd, st) { const kind = mine().kind; const a = cmd.action; sync.doneCmd = cmd.id; sync.quiet = true; try { if (a === 'transfer') { takeOver(st); } else if (a === 'sleep') { // minutes; -n: after n episodes; 0: off const v = Math.round(Number(cmd.value) || 0); sleep.set(Math.max(v, 0), v < 0 ? -v : 0); } else if (kind === 'radio') { if (a === 'play' && !radioPlaying()) togglePlayStop(); else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false); } else if (kind === 'podcast') { if (a === 'play') resume(); else if (a === 'pause' || a === 'stop') pause(); else if (a === 'seek') seek(Number(cmd.value) || 0); else if (a === 'skip') { const to = posMs() + (Number(cmd.value) || 0) * 1000; const dur = durMs(); seek(dur > 0 ? Math.min(to, dur - 1000) : to); } else if (a === 'next') next(); } } finally { sync.quiet = false; } syncReport(); } // Another device started playing: this one stops (the episode keeps its place). function handOver(st) { if (!wantPlay && !radioPlaying()) return; sync.quiet = true; try { if (wantPlay) pause(); if (radioPlaying()) stopPlayback(false); } finally { sync.quiet = false; } toast('Läuft jetzt auf ' + (st.device_name || 'einem anderen Gerät')); } // Load an episode paused at a place (adopting, or before playing it here). function loadAt(ep, at) { at = Math.max(0, Math.round(at || 0)); if (curId() === ep.id) { if (loaded && audio.readyState > 0) { try { audio.currentTime = at / 1000; } catch (e) {} } else { startAt = at; } } else { if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true); unload(); cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep)); startAt = at; wantPlay = false; buffering = false; error = null; lastDur = 0; storePlayer(); } mustReach = null; pausedAt = 0; lastPos = at; cur.position_seconds = Math.floor(at / 1000); forEpisode(state, ep.id, o => { o.position_seconds = Math.floor(at / 1000); }); save(); report(); } // The episode that waits here, paused, follows a newer place from elsewhere // (another device played on): never while it plays here, never over a place // of ours that isn't saved yet, not for a few seconds' difference. function movePausedTo(id, at) { if (curId() !== id || wantPlay) return false; if (outbox.some(op => op.key === 'progress:' + id)) return false; if (Math.abs(at - posMs()) <= 5000) return false; loadAt({id}, at); return true; } // After fetching the podcast state: the saved place of that episode may be newer. function followSavedPlace() { if (!cur) return; let secs = null; forEpisode(state, cur.episode__id, o => { if (secs == null && o.position_seconds != null) secs = o.position_seconds; }); if (secs == null) return; // Where resuming would start (resumeMs): from 5 s on, past the podcast's intro. movePausedTo(cur.episode__id, Math.max(secs > 5 ? secs * 1000 : 0, introMs(curFeed()))); } // Play what the shared player has, here, from its place ("Hier abspielen", a transfer). function takeOver(st) { st = st || sync.state; if (st && st.kind === 'radio' && st.station) { playStation(st.station.url, st.station.name || '', st.station.id || null); } else if (st && st.kind === 'podcast' && st.episode) { loadAt(st.episode, placeOf(st)); resume(); } else if (cur) { resume(); } } // The active device is gone and we play nothing: its item becomes ours, paused. function adopt(st) { if (wantPlay || radioPlaying()) return; if (st.kind === 'podcast' && st.episode) { if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms); else loadAt(st.episode, st.position_ms); } else if (st.kind === 'radio' && st.station) { const now = typeof currentStation !== 'undefined' ? currentStation : null; if (cur || !now || now.url !== st.station.url) { sync.quiet = true; try { putAway(); } finally { sync.quiet = false; } window.__dioraAdoptStation(st.station); } } sync.ownAt = st.server_time; store('sync_at', sync.ownAt); } // What a command of ours will do shows at once (the device reports within a second or so). function hintFor(action, value) { const st = sync.state; const r = remoteJson(); if (!r) return; const h = {updatedAt: st.updated_at, until: Date.now() + 5000, at: Date.now(), pos: r.pos, playing: r.playing}; if (action === 'play') h.playing = true; else if (action === 'pause' || action === 'stop') h.playing = false; else if (action === 'seek') h.pos = value; else if (action === 'skip') h.pos = Math.max(0, r.pos + value * 1000); else return; sync.hint = h; report(); } // Remote control: a command for the active device (or `to`). function command(action, value, to) { const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action}; if (action === 'seek' || action === 'skip' || action === 'sleep') body.value = Math.round(Number(value) || 0); if (to) body.to_device_id = to; else hintFor(action, body.value); return post('/podcasts/player/command/', body) .then(r => { if (r.ok) return r.json(); return r.json().catch(() => ({})).then(d => { toast(d && d.error === 'nothing to transfer' ? 'Hier läuft nichts, das sich verschieben ließe' : 'Das Gerät ist gerade nicht erreichbar'); return null; }); }) .catch(() => { toast('Keine Verbindung zum Server'); return null; }) .then(st => { if (!st) { sync.hint = null; report(); } heard(st); }); } // Move the playback to a device: here (take over), or there (it plays from the place, we stop). function transfer(id) { if (id === DEVICE_ID) { takeOver(); return; } // What we have isn't shared yet (we're the active device, or there is none): first say what it is. (otherActive(sync.state) ? Promise.resolve() : syncReport()).then(() => command('transfer', 0, id)); } // The stream: every change, as it happens. The server ends it every few minutes // (and the network may drop it): then again, with the last seq we know. let source = null; let retryMs = 1000; let retryTimer = 0; function listen() { clearTimeout(retryTimer); if (source) source.close(); const seen = Math.max(sync.state ? sync.state.seq : -1, sync.later ? sync.later.seq : -1); const q = new URLSearchParams({since: String(seen), device_id: DEVICE_ID, device_name: DEVICE_NAME, device_kind: OPT.deviceKind}); source = new EventSource('/podcasts/player/stream/?' + q); source.onopen = () => { retryMs = 1000; }; source.onmessage = e => heard(parse(e.data, null)); source.onerror = () => { if (source) source.close(); source = null; retryTimer = setTimeout(listen, retryMs); retryMs = Math.min(retryMs * 2, 30000); }; } function fetchPlayer() { return fetch('/podcasts/player/', {credentials: 'same-origin', cache: 'no-store'}) .then(r => (r.ok ? r.json() : null)) .catch(() => null) .then(heard); } function startSync() { fetchPlayer().then(listen); } // Leaving while playing: the others shouldn't show us as playing until we count as gone. function syncLeaving() { const m = mine(); if (!m.playing || !sync.state || sync.state.device_id !== DEVICE_ID || !navigator.sendBeacon) return; m.playing = false; navigator.sendBeacon('/podcasts/player/report/', JSON.stringify(Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, m))); } // ---- what the page and the lock screen see --------------------------------------------- const decoder = document.createElement('textarea'); function plain(s) { if (!s || String(s).indexOf('&') < 0) return s || ''; decoder.innerHTML = s; return decoder.value; } function playerJson() { const fid = curFeed(); const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json(), remote: remoteJson(), devices: devicesJson()}; if (!cur) { o.kind = null; return o; } o.kind = 'podcast'; o.episodeId = cur.episode__id; o.feedId = fid || 0; o.title = cur.episode__title || ''; o.feed = cur.episode__feed__title || ''; o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || ''; o.pos = Math.round(posMs()); o.dur = Math.round(durMs()); o.playing = wantPlay; o.buffering = buffering && wantPlay; if (error) o.error = error; return o; } function report() { if (window.__dioraPlayer) window.__dioraPlayer(playerJson()); if (!('mediaSession' in navigator) || !cur || sessionFor !== cur.episode__id) return; const ms = navigator.mediaSession; ms.playbackState = wantPlay ? 'playing' : 'paused'; const dur = durMs() / 1000; if (dur > 0 && ms.setPositionState) { try { ms.setPositionState({duration: dur, playbackRate: audio.playbackRate || 1, position: Math.min(posMs() / 1000, dur)}); } catch (e) {} } } // Lock screen, notification and media keys: this episode, skip back and forward, next. function setSession() { if (!('mediaSession' in navigator) || !cur) return; const ms = navigator.mediaSession; const art = (cur.episode__artwork_url || cur.episode__feed__artwork_url || '').replace(/^http:/, 'https:'); try { ms.metadata = new MediaMetadata({ title: plain(cur.episode__title), artist: plain(cur.episode__feed__title), album: plain(cur.episode__feed__title), artwork: art ? [{src: art}] : [], }); } catch (e) {} const on = (name, fn) => { try { ms.setActionHandler(name, fn); } catch (e) {} }; on('play', () => resume()); on('pause', () => pause()); on('stop', () => pause()); on('seekbackward', () => skip(false)); on('seekforward', () => skip(true)); on('seekto', d => { if (d && d.seekTime != null) seek(d.seekTime * 1000); }); on('nexttrack', () => next()); on('previoustrack', null); sessionFor = cur.episode__id; } let toastTimer = 0; function toast(text) { let t = document.getElementById('nui-radio-toast'); if (!t) { t = document.createElement('div'); t.id = 'nui-radio-toast'; document.body.appendChild(t); } t.textContent = text; t.classList.add('nui-show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); } // ---- the host API (Bridge.kt's podcast part) ------------------------------------------- const ids = json => (parse(json, []) || []).map(Number); const sent = fn => function () { fn.apply(null, arguments); flush(); }; window.DioraPodcastHost = { podcastState: () => JSON.stringify(state), podcastRefresh(fetchFeeds) { refresh(!!fetchFeeds).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); }); }, podcastFeedRemoved() { refresh(false).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); }); }, podcastFavoriteChanged() {}, podcastEnqueue: sent((json, where, currentId) => enqueue(parse(json, []) || [], where || null, currentId > 0 ? currentId : null)), podcastDequeue: sent(id => dequeueAll([id])), podcastDequeueAll: sent(json => dequeueAll(ids(json))), podcastMove: sent((id, index) => move(id, index)), podcastReorder: sent(json => reorder(ids(json))), podcastMarkPlayed(id, played) { markPlayedAll([id], !!played, prefs.remove_played); // "Unplayed" brings it back into Neu on the server; fetch that. if (played) flush(); else refresh(false); }, podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)), podcastDismiss: sent(json => dismiss(ids(json))), podcastDiscard: sent(json => discard(ids(json))), podcastSettings: sent(json => setSettings(parse(json, {}) || {})), podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)), podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})), podcastFeedPrefs: sent((feedId, json) => feedPrefs(feedId, parse(json, {}) || {})), podcastPrefs: () => JSON.stringify(prefsJson()), podcastSetPref: (name, value) => setPref(name, value ? 1 : 0), podcastSetPrefValue: (name, value) => setPref(name, Number(value)), podcastPlay: id => playEpisode(id), // Play an episode; one that isn't queued yet is queued first (where the setting says). podcastPlayEpisode(json, currentId) { const ep = parse(json, {}) || {}; const id = ep.episode__id != null ? ep.episode__id : ep.id; if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null); playEpisode(id); flush(); }, podcastPlayEpisodeAt(json, currentId, ms) { const ep = parse(json, {}) || {}; const id = ep.episode__id != null ? ep.episode__id : ep.id; if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null); playEpisodeAt(id, Math.max(0, Number(ms) || 0)); flush(); }, podcastNext: () => next(), podcastToggle: () => (wantPlay ? pause() : resume()), 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 = '
'; 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 = ''; b.querySelector('.nui-row-icon').textContent = it.icon || ''; b.querySelector('.nui-row-label').textContent = it.label; if (it.hint) { const h = document.createElement('span'); h.className = 'nui-row-hint'; h.textContent = it.hint; b.querySelector('.nui-row-text').appendChild(h); } b.addEventListener('click', () => { closeSheet(); it.action(); }); panel.appendChild(b); } wrap.addEventListener('click', e => { e.stopPropagation(); if (e.target.classList.contains('nui-backdrop')) closeSheet(); }); document.body.appendChild(wrap); sheetEl = wrap; const first = panel.querySelector('button'); if (first) first.focus({preventScroll: true}); }; window.DioraUI = {openSheet, closeSheet}; } // ---- page set-up ------------------------------------------------------------------------- // The browser layout (podcasts-web.css hangs off html.dpw); diora desktop keeps the phone's. if (OPT.styles !== 'ui-only') document.documentElement.classList.add('dpw'); for (const href of UI_STYLES) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); } // The dock the page puts its player bar in (native-radio.js builds it in the app): // here it is part of the radio bar at the bottom, in place of its "now playing", // so radio and podcasts share one bar. // diora desktop: native-radio.js builds the dock (on DOMContentLoaded) with the tab bar. if (OPT.dock !== 'existing') { const dock = document.createElement('div'); dock.id = 'nui-dock'; const bar = document.getElementById('now-playing-bar'); if (bar) { bar.insertBefore(dock, bar.firstChild); document.documentElement.classList.add('dpw-in-bar'); } else { document.body.appendChild(dock); } // The page keeps clear of the bar, whose height changes with the screen. const placeDock = () => { const h = bar ? bar.getBoundingClientRect().height : 0; document.documentElement.style.setProperty('--dpw-bar-h', Math.round(h) + 'px'); }; placeDock(); if (bar && window.ResizeObserver) new ResizeObserver(placeDock).observe(bar); window.addEventListener('resize', placeDock); } // Escape closes what the podcast page has open: a sheet, the player, notes, // a podcast, choosing several (its back handler) — never the tab itself. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || e.defaultPrevented) return; if (window.DioraUI.closeSheet && window.DioraUI.closeSheet()) return; const podTab = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts'; const open = document.getElementById('nui-pod-player') || (podTab && document.querySelector('#nui-pod .nui-pod-feedhead, #nui-pod .nui-pod-selbar')); if (open && window.__dioraBack) window.__dioraBack(); }); // The episode that was in the player before the page was left (not loaded until played). const was = load('player', null); if (was && was.entry && was.entry.episode__id != null) { cur = entry(was.entry.episode__id) || was.entry; lastPos = resumeMs(); } // Keep the place when the page goes away; send what's pending when the connection is back. const leaving = () => { if (cur && loaded && audio.readyState > 0) { saveProgress(cur.episode__id, Math.floor(posMs() / 1000)); if (navigator.sendBeacon) { navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify( {episode_id: cur.episode__id, position_seconds: Math.floor(posMs() / 1000)})); } } }; window.addEventListener('pagehide', leaving); window.addEventListener('pagehide', syncLeaving); // Back in the foreground: another device may have played meanwhile (its place, what plays). let lastVisible = 0; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true); } else if (Date.now() - lastVisible > 15000) { lastVisible = Date.now(); refresh(false); fetchPlayer(); if (!source) listen(); } }); window.addEventListener('online', () => { flush(); if (!source) listen(); }); // The shared player: what plays on the other devices, and from now on every change. startSync(); // The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the // last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab. (function openStartPage() { let start = ''; try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {} if (!start || typeof showTab !== 'function') return; const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; if (nav && nav.type !== 'navigate') return; const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books'; if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab); })(); // The page (copied from the app) comes last, once all of the above is in place — // 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); })();