/** * Podcasts in the browser: the phone app's podcast page on top of a browser * version of what the app does in Kotlin. * * The page itself is diora-android's web-native/static/native-podcast.js, * copied unchanged to /static/podcasts/ by tools/sync_podcast_ui.py. It talks * to its host only through a small API (window.DioraNative in the app, * window.DioraPodcastHost here) and hears back through window.__dioraPodcasts, * __dioraPodcastsRefreshed and __dioraPlayer. This file is that host: * * - the state (feeds, queue, inbox, settings) from GET /podcasts/state/, * kept in localStorage for a fast, offline-capable start (Podcasts.kt); * - every change applied to that copy at once and sent to the server in * order through an outbox that survives reloads and going offline; * - a player of its own (not app.js's radio element) whose playlist is the * queue, with the app's bookkeeping (RadioService.EpisodeTracker): resume, * intro/outro, progress every 10 s and on pause, "played" near the end, * moving on by itself, rewinding a little after a pause, per-podcast * speed and volume, and the sleep timer (SleepTimer.kt); * - lock-screen and media-key controls through navigator.mediaSession; * - this device's playback settings in localStorage (PlaybackPrefs.kt). * * Radio and podcasts never play at the same time: starting an episode stops * the radio (app.js stopPlayback), starting a station puts the episode away. * * In the app (and the desktop app) window.DioraNative exists and this file * does nothing. Written without ?. and ?? so old WebViews can still parse it. */ (function () { 'use strict'; if (window.DioraNative) return; if (typeof IS_AUTHENTICATED === 'undefined' || !IS_AUTHENTICATED) return; if (!document.getElementById('tab-podcasts')) return; const UI_SCRIPT = '/static/podcasts/native-podcast.js'; const UI_STYLES = ['/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css']; // ---- storage ------------------------------------------------------------------ const USER = String(window.USER_ID || ''); function load(name, fallback) { try { const v = localStorage.getItem('dpw_' + name + '_' + USER); return v ? JSON.parse(v) : fallback; } catch (e) { return fallback; } } function store(name, value) { try { localStorage.setItem('dpw_' + name + '_' + USER, JSON.stringify(value)); } catch (e) {} } function parse(s, fallback) { try { return typeof s === 'string' ? JSON.parse(s) : s; } catch (e) { return fallback; } } function csrf() { return typeof getCsrfToken === 'function' ? getCsrfToken() : ''; } // ---- state and outbox (Podcasts.kt) ---------------------------------------------- const POSITIONS = ['back', 'front', 'after_current']; const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration']; function empty() { return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; } let state = load('state', null) || empty(); let outbox = load('outbox', []); let lastRefresh = 0; function save() { store('state', state); store('outbox', outbox); } function changed() { if (window.__dioraPodcasts) window.__dioraPodcasts(JSON.stringify(state)); } function edit(block) { block(state); save(); changed(); } function queueOp(path, body, key) { if (key) outbox = outbox.filter(op => op.key !== key); const op = {path, body}; if (key) op.key = key; outbox.push(op); } let flushing = null; // Send queued changes in order. False when the server can't be reached. function flush() { if (flushing) return flushing; flushing = sendAll().then(ok => { flushing = null; return ok; }, () => { flushing = null; return false; }); return flushing; } async function sendAll() { while (outbox.length) { const op = outbox[0]; let r; try { r = await fetch(op.path, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, body: JSON.stringify(op.body), }); } catch (e) { return false; } if (r.status === 401 || r.status >= 500) return false; // Another 4xx: the server refuses this change (episode gone …); drop it // rather than block everything behind it. const i = outbox.indexOf(op); if (i >= 0) outbox.splice(i, 1); save(); } return true; } // Fetch the state in one request (with fetchFeeds the server fetches all // feeds first). Changes not sent yet go first; while some are still // pending, the local copy stays. async function refresh(fetchFeeds) { try { if (fetchFeeds) { if (!(await flush())) return false; await fetch('/podcasts/feeds/refresh-all/', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, body: '{"max_age":60}', }); } // Progress is saved every few seconds while an episode plays; try again // when it slipped in while the state was on its way. for (let attempt = 0; attempt < 3; attempt++) { if (!(await flush())) return false; const r = await fetch('/podcasts/state/?inbox_limit=200', {credentials: 'same-origin', cache: 'no-store'}); if (r.status !== 200) return false; const fresh = await r.json(); if (outbox.length) continue; state = fresh; lastRefresh = Date.now(); save(); changed(); return true; } return false; } catch (e) { return false; } } const queueOf = s => s.queue || (s.queue = []); const queueIds = s => queueOf(s).map(q => q.episode__id); function entry(id) { return queueOf(state).find(q => q.episode__id === id) || null; } function feed(id) { return (state.feeds || []).find(f => f.id === id) || null; } // The queue order a "keep sorted" rule gives (Podcasts.keepSortedOrder; the server sorts the same way). function keepSortedOrder(entries, rule) { const date = o => (o.episode__pub_date ? String(o.episode__pub_date) : null); const left = o => (o.episode__duration_seconds || 0) - (o.position_seconds || 0); const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0); const byDate = desc => (a, b) => { const da = date(a), db = date(b); if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; return desc ? cmpStr(db, da) : cmpStr(da, db); }; let cmp; if (rule === 'new') cmp = byDate(true); else if (rule === 'old') cmp = byDate(false); else if (rule === 'short') cmp = (a, b) => left(a) - left(b); else if (rule === 'long') cmp = (a, b) => left(b) - left(a); else if (rule === 'feed') { cmp = (a, b) => cmpStr(String(a.episode__feed__title || '').toLowerCase(), String(b.episode__feed__title || '').toLowerCase()) || byDate(true)(a, b); } else return entries.map(o => o.episode__id); return entries.slice().sort(cmp).map(o => o.episode__id); // Array.sort is stable } function setQueueOrder(s, order) { const byId = new Map(queueOf(s).map(q => [q.episode__id, q])); const rule = (s.settings && s.settings.keep_sorted) || ''; const final = rule ? keepSortedOrder(order.map(id => byId.get(id)).filter(Boolean), rule) : order; s.queue = final.map(id => byId.get(id)).filter(Boolean); s.queue.forEach((q, i) => { q.position = i; }); } // An inbox/feed episode as a queue entry (the server's queue/ field names). function asQueueEntry(ep) { if (ep.episode__id != null) return ep; return { episode__id: ep.id, episode__title: ep.title || '', episode__audio_url: ep.audio_url || '', episode__duration_seconds: ep.duration_seconds || 0, episode__artwork_url: ep.artwork_url || '', episode__pub_date: ep.pub_date || null, episode__feed__id: ep.feed__id != null ? ep.feed__id : ep.feed_id, episode__feed__title: ep.feed__title || ep.feed_title || '', episode__feed__artwork_url: ep.feed__artwork_url || '', position_seconds: ep.position_seconds || 0, }; } function removeFromInbox(s, ids) { s.inbox = (s.inbox || []).filter(o => !ids.has(o.id)); } function markInQueue(s, ids, inQueue) { for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue; } function forEpisode(s, id, block) { for (const o of queueOf(s)) if (o.episode__id === id) block(o); for (const o of s.inbox || []) if (o.id === id) block(o); } function enqueue(episodes, where, currentId) { if (!episodes.length) return; const pos = POSITIONS.indexOf(where) >= 0 ? where : (state.settings && state.settings.enqueue_position) || 'back'; edit(s => { const order = queueIds(s); const seen = new Set(order); const fresh = []; for (const e of episodes.map(asQueueEntry)) { if (!seen.has(e.episode__id)) { seen.add(e.episode__id); fresh.push(e); } } if (!fresh.length) return; for (const e of fresh) queueOf(s).push(e); let at = order.length; if (pos === 'front') at = 0; else if (pos === 'after_current') { const i = currentId ? order.indexOf(currentId) : -1; at = i >= 0 ? i + 1 : 0; } const ids = fresh.map(e => e.episode__id); setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at))); removeFromInbox(s, new Set(ids)); // queued means no longer new const body = {episode_ids: ids, where: pos}; if (currentId) body.current_episode_id = currentId; queueOp('/podcasts/queue/add/', body); }); } function dequeueAll(ids) { edit(s => { const gone = new Set(ids); setQueueOrder(s, queueIds(s).filter(id => !gone.has(id))); markInQueue(s, gone, false); for (const id of ids) queueOp('/podcasts/queue/remove/', {episode_id: id}); }); } function reorder(order) { edit(s => { const now = queueIds(s); const wanted = []; for (const id of order) if (now.indexOf(id) >= 0 && wanted.indexOf(id) < 0) wanted.push(id); const final = wanted.concat(now.filter(id => wanted.indexOf(id) < 0)); if (final.join() === now.join()) return; setQueueOrder(s, final); queueOp('/podcasts/queue/reorder/', {order: queueIds(s)}); }); } function move(id, index) { edit(s => { const order = queueIds(s); const at = order.indexOf(id); if (at < 0) return; order.splice(at, 1); order.splice(Math.max(0, Math.min(index, order.length)), 0, id); setQueueOrder(s, order); queueOp('/podcasts/queue/reorder/', {order}); }); } function saveProgress(id, seconds) { forEpisode(state, id, o => { o.position_seconds = seconds; }); if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new queueOp('/podcasts/progress/save/', {episode_id: id, position_seconds: seconds}, 'progress:' + id); save(); // No re-render: progress changes every few seconds while playing. } // Finished or marked by hand: played and out of the inbox; out of the queue // too unless leaveQueue is false (the user's setting). function markPlayedAll(ids, played, leaveQueue) { edit(s => { for (const id of ids) queueOp('/podcasts/progress/mark-played/', {episode_id: id, played}); if (!played) return; const set = new Set(ids); removeFromInbox(s, set); for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; }); if (leaveQueue) { const inQueue = queueIds(s).filter(id => set.has(id)); if (inQueue.length) { setQueueOrder(s, queueIds(s).filter(id => !set.has(id))); for (const id of inQueue) queueOp('/podcasts/queue/remove/', {episode_id: id}); } } }); } function dismiss(ids) { edit(s => { removeFromInbox(s, new Set(ids)); queueOp('/podcasts/progress/dismiss/', {episode_ids: ids}); }); } function setSettings(changes) { edit(s => { const settings = s.settings || (s.settings = {}); const body = {}; for (const name of ['enqueue_position', 'auto_queue_position']) { if (POSITIONS.indexOf(changes[name]) >= 0) settings[name] = body[name] = changes[name]; } if ('keep_sorted' in changes && SORT_RULES.indexOf(changes.keep_sorted) >= 0) { settings.keep_sorted = body.keep_sorted = changes.keep_sorted; if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once } if (Object.keys(body).length) queueOp('/podcasts/settings/', body); }); } // A podcast's own settings, kept on the server so every device has them. function setFeedSettings(feedId, changes) { edit(s => { const f = (s.feeds || []).find(x => x.id === feedId); if (!f) return; const body = {}; for (const key of Object.keys(changes)) { if (FEED_SETTINGS.indexOf(key) < 0) continue; f[key] = body[key] = changes[key]; } if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue'; if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body); }); if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); } } function setAutoQueue(feedId, on) { edit(s => { const f = (s.feeds || []).find(x => x.id === feedId); if (f) f.auto_queue = on; queueOp('/podcasts/feeds/' + feedId + '/set-auto-queue/', {auto_queue: on}); }); } // ---- this device's playback settings (PlaybackPrefs.kt) -------------------------- const CHOICES = { skip_back: [5, 10, 15, 30], skip_forward: [10, 15, 30, 45, 60], smart_played: [0, 15, 30, 60, 120, 300, 600], }; const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30, sleep_last: 15, sleep_auto: false}, load('prefs', {})); function setPref(name, value) { if (name === 'speed') { if (!(value >= 0.5 && value <= 3)) return; prefs.speed = value; } else if (CHOICES[name]) { if (CHOICES[name].indexOf(Math.round(value)) < 0) return; prefs[name] = Math.round(value); } else if (name === 'remove_played') { prefs.remove_played = !!value; } else { return; // skip_silence, headset_next: nothing a browser can do } store('prefs', prefs); } // What the page shows as settings. No skip_silence / headset_next: the page leaves those out. function prefsJson() { return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward, remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}}; } function speedFor(feedId) { const f = feed(feedId); return f && f.playback_speed > 0 ? f.playback_speed : prefs.speed; } const hasOwnSpeed = feedId => { const f = feed(feedId); return !!(f && f.playback_speed > 0); }; const introMs = feedId => { const f = feed(feedId); return ((f && f.skip_intro) || 0) * 1000; }; const outroMs = feedId => { const f = feed(feedId); return ((f && f.skip_outro) || 0) * 1000; }; const volumeFor = feedId => { const f = feed(feedId); return f && f.volume > 0 ? f.volume : 1; }; // The page's per-podcast chips speak the app's local format: {speed, intro, outro, volume}. function feedPrefs(feedId, changes) { const body = {}; if ('speed' in changes) body.playback_speed = changes.speed > 0 ? changes.speed : null; if ('intro' in changes) body.skip_intro = Math.round(changes.intro || 0); if ('outro' in changes) body.skip_outro = Math.round(changes.outro || 0); if ('volume' in changes) body.volume = changes.volume > 0 ? changes.volume : 1; setFeedSettings(feedId, body); } // ---- sleep timer (SleepTimer.kt): minutes count only while playing ---------------- const FADE_MS = 10000; const MAX_GAP_MS = 10000; const sleep = { leftMs: 0, episodes: 0, lastTick: 0, get active() { return this.leftMs > 0 || this.episodes > 0; }, set(minutes, episodes) { this.leftMs = minutes * 60000; this.episodes = minutes > 0 ? 0 : episodes; this.lastTick = 0; if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); } setFade(1); }, extend(minutes) { if (this.leftMs <= 0) { this.set(minutes, 0); return; } this.leftMs += minutes * 60000; setFade(1); }, tick(playing) { const now = Date.now(); const delta = this.lastTick ? now - this.lastTick : 0; this.lastTick = now; if (this.leftMs <= 0 || !playing) return; // A gap means nothing was ticking (paused, tab asleep): not playing time. if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta; if (this.leftMs <= 0) { this.leftMs = 0; setFade(1); pause(); stopRadio(); // the radio sleeps too (it plays in app.js's element) return; } if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1)); }, // An episode finished: true when playing should stop here. episodeFinished() { if (this.episodes <= 0) return false; this.episodes--; return this.episodes === 0; }, queueEnded() { this.episodes = 0; }, // An episode starts playing: at night, the timer may switch itself on. maybeAutoEnable() { if (this.active || !prefs.sleep_auto) return; const hour = new Date().getHours(); if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0); }, json() { return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15}; }, }; // ---- the player (RadioService.EpisodeTracker) --------------------------------------- const audio = new Audio(); // the radio keeps app.js's own element audio.preload = 'auto'; let cur = null; // queue entry (a copy) of the episode in the player let loaded = false; // its address is set on the audio element let wantPlay = false; // play when ready (what the page shows as playing) let startAt = null; // ms to start from when it gets loaded let lastPos = 0; // ms let lastDur = 0; // ms let lastSaved = 0; let pausedAt = 0; let outroDone = null; // episode whose outro was skipped already let fade = 1; // the sleep timer's fade-out let buffering = false; let error = null; let sessionFor = null; // episode the media session shows let mustReach = null; // ms the first seek after loading should have reached const curId = () => (cur ? cur.episode__id : null); const curFeed = () => (cur ? cur.episode__feed__id : null); function durMs() { if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000; return lastDur || ((cur && cur.episode__duration_seconds) || 0) * 1000; } function posMs() { if (mustReach != null) return mustReach; // the start place, until it's confirmed if (loaded && audio.readyState > 0) return audio.currentTime * 1000; return startAt != null ? startAt : lastPos; } // Where this episode picks up: its place (the queue knows it best), past the podcast's intro. function resumeMs() { const q = cur ? entry(cur.episode__id) : null; const resume = ((q || cur || {}).position_seconds || 0) * 1000; return Math.max(resume > 5000 ? resume : 0, introMs(curFeed())); } // Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished. function almostEnded() { const dur = durMs(); return dur > 0 && posMs() >= dur - Math.max(prefs.smart_played * 1000, 1000); } function nextEntry() { const q = queueOf(state); const at = q.findIndex(e => e.episode__id === curId()); if (at >= 0) return q[at + 1] || null; return q.length && q[0].episode__id !== curId() ? q[0] : null; } function saveNow(id, send) { lastSaved = Date.now(); saveProgress(id, Math.floor(posMs() / 1000)); if (send) flush(); } // An episode is done: played, out of the queue (setting). True when the sleep timer says stop. function finished(id) { markPlayedAll([id], true, prefs.remove_played); flush(); return sleep.episodeFinished(); } function setFade(f) { fade = f; applyVolume(); } // 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; else it keeps its place. function goTo(e, auto, play) { const prev = curId(); let stop = false; if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (auto || almostEnded()) stop = finished(prev); else saveNow(prev, true); } unload(); cur = Object.assign({}, e); startAt = null; pausedAt = 0; buffering = false; error = null; lastDur = 0; lastPos = resumeMs(); storePlayer(); if (play && !stop) resume(); else { wantPlay = false; report(); } } // A pause rewinds a little on the way back in, to find the thread again. function catchUp() { if (!pausedAt) return; const paused = Date.now() - pausedAt; pausedAt = 0; mustReach = null; const back = paused > 24 * 3600000 ? 20000 : paused > 3600000 ? 10000 : paused > 60000 ? 3000 : 800; try { audio.currentTime = Math.max(0, audio.currentTime - back / 1000); } catch (e) {} } function resume() { if (!cur) return; stopRadio(); if (!loaded) prepare(); else catchUp(); wantPlay = true; applyKind(); setSession(); const p = audio.play(); if (p && p.catch) { p.catch(err => { if (err && err.name === 'AbortError') return; // a newer load or pause came first wantPlay = false; buffering = false; if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen'); report(); }); } report(); } function pause() { wantPlay = false; if (!audio.paused) audio.pause(); pausedAt = pausedAt || Date.now(); report(); } function seek(ms) { if (!cur) return; ms = Math.max(0, ms); mustReach = null; if (loaded && audio.readyState > 0) { try { audio.currentTime = ms / 1000; } catch (e) {} } else { startAt = ms; } lastPos = ms; report(); } function skip(forward) { const pos = posMs(); if (!forward) { seek(pos - prefs.skip_back * 1000); return; } const to = pos + prefs.skip_forward * 1000; const dur = durMs(); seek(dur > 0 ? Math.min(to, dur - 1000) : to); } function next() { const n = nextEntry(); if (n) goTo(n, false, wantPlay); } function playEpisode(id) { if (curId() === id) { resume(); return; } const e = entry(id); if (e) goTo(e, false, true); } // The podcast's outro is skipped: the episode counts as finished there. function skipOutro(id) { if (outroDone === id) return; const outro = outroMs(curFeed()); const dur = durMs(); if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return; outroDone = id; const n = nextEntry(); // before finished() takes this one out of the queue if (finished(id) || !n) pause(); else goTo(n, false, true); } function onEnded() { const id = curId(); if (id == null) return; const n = nextEntry(); if (n) { goTo(n, true, true); return; } if (id !== outroDone) finished(id); outroDone = id; sleep.queueEnded(); wantPlay = false; report(); } // A browser that pauses the element while it loads (power saving) may drop // that first seek: check it once the audio can play. const reached = () => { if (mustReach == null || audio.seeking) return; if (Math.abs(audio.currentTime * 1000 - mustReach) > 3000) { try { audio.currentTime = mustReach / 1000; } catch (e) {} } mustReach = null; }; audio.addEventListener('canplay', reached); audio.addEventListener('playing', () => { reached(); buffering = false; if (!wantPlay) wantPlay = true; sleep.maybeAutoEnable(); report(); }); audio.addEventListener('waiting', () => { buffering = true; report(); }); audio.addEventListener('canplay', () => { if (buffering && audio.paused) { buffering = false; report(); } }); audio.addEventListener('pause', () => { if (!loaded || audio.ended) return; // Paused here, by the lock screen, or by the system (headphones out …). wantPlay = false; pausedAt = pausedAt || Date.now(); if (cur) saveNow(cur.episode__id, true); report(); }); audio.addEventListener('ended', onEnded); audio.addEventListener('loadedmetadata', () => { if (startAt != null) { const at = startAt; startAt = null; if (at > 0) { try { audio.currentTime = at / 1000; } catch (e) {} mustReach = at; } } if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; applyKind(); report(); }); audio.addEventListener('error', () => { if (!loaded || !audio.getAttribute('src')) return; error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0); wantPlay = false; buffering = false; toast('Diese Folge lässt sich gerade nicht abspielen'); report(); }); // Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer. setInterval(() => { const id = curId(); const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2; if (id != null && loaded && audio.readyState > 0) { lastPos = audio.currentTime * 1000; if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; if (playing && Date.now() - lastSaved > 10000) saveNow(id, true); if (playing) skipOutro(id); } // The sleep timer counts while either plays (radio and podcasts share the player). sleep.tick(playing || radioPlaying()); if (playing || sleep.leftMs > 0) report(); }, 1000); // ---- radio and podcasts take turns --------------------------------------------------- function stopRadio() { try { if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false); } catch (e) {} } // A station starts: the episode keeps its place and leaves the player (the app does the same). function putAway() { if (!cur) return; if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true); wantPlay = false; unload(); cur = null; storePlayer(); report(); if (sessionFor != null && 'mediaSession' in navigator) { // The station sets its own handlers; these two it doesn't know about. try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {} try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {} sessionFor = null; } } if (typeof window.playStation === 'function') { const playStation = window.playStation; window.playStation = function () { putAway(); const r = playStation.apply(this, arguments); radioChanged(); return r; }; } // ---- the radio in the same player (in the app native-radio.js does this) ---------- // The podcast page's bar and full player show the station too; app.js keeps // playing it, its own bar keeps only volume, votes, focus mode and timer. function radioChanged() { if (window.__dioraRadioChanged) window.__dioraRadioChanged(); } function radioSaved(url) { return [...document.querySelectorAll('#saved-tbody tr[data-url]')].some(r => r.dataset.url === url); } function radioLogo(st) { const all = [].concat(typeof INITIAL_SAVED !== 'undefined' ? INITIAL_SAVED : [], typeof INITIAL_FEATURED !== 'undefined' ? INITIAL_FEATURED : []); const s = all.find(x => x && x.url === st.url); return (s && s.favicon_url) || ''; } const radioPlaying = () => typeof isPlaying !== 'undefined' && !!isPlaying; window.__dioraRadioNow = function () { const st = typeof currentStation !== 'undefined' ? currentStation : null; if (!st) return null; return { name: st.name || '', logo: radioLogo(st), track: typeof currentTrack !== 'undefined' ? currentTrack : '', playing: radioPlaying(), buffering: false, saved: radioSaved(st.url), }; }; // Stop keeps the station (like a paused episode): the bar stays with ▶. window.__dioraRadioToggle = () => (radioPlaying() ? stopPlayback(false) : togglePlayStop()); window.__dioraRadioSave = () => saveCurrentStation(); for (const name of ['stopPlayback', 'updateNowPlayingUI', 'saveCurrentStation']) { const fn = window[name]; if (typeof fn !== 'function') continue; window[name] = function () { const r = fn.apply(this, arguments); if (r && typeof r.then === 'function') r.then(radioChanged, radioChanged); else radioChanged(); return r; }; } const savedRows = document.getElementById('saved-tbody'); if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true}); // ---- what the page and the lock screen see --------------------------------------------- const decoder = document.createElement('textarea'); function plain(s) { if (!s || String(s).indexOf('&') < 0) return s || ''; decoder.innerHTML = s; return decoder.value; } function playerJson() { const fid = curFeed(); const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json()}; if (!cur) { o.kind = null; return o; } o.kind = 'podcast'; o.episodeId = cur.episode__id; o.feedId = fid || 0; o.title = cur.episode__title || ''; o.feed = cur.episode__feed__title || ''; o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || ''; o.pos = Math.round(posMs()); o.dur = Math.round(durMs()); o.playing = wantPlay; o.buffering = buffering && wantPlay; if (error) o.error = error; return o; } function report() { if (window.__dioraPlayer) window.__dioraPlayer(playerJson()); if (!('mediaSession' in navigator) || !cur || sessionFor !== cur.episode__id) return; const ms = navigator.mediaSession; ms.playbackState = wantPlay ? 'playing' : 'paused'; const dur = durMs() / 1000; if (dur > 0 && ms.setPositionState) { try { ms.setPositionState({duration: dur, playbackRate: audio.playbackRate || 1, position: Math.min(posMs() / 1000, dur)}); } catch (e) {} } } // Lock screen, notification and media keys: this episode, skip back and forward, next. function setSession() { if (!('mediaSession' in navigator) || !cur) return; const ms = navigator.mediaSession; const art = (cur.episode__artwork_url || cur.episode__feed__artwork_url || '').replace(/^http:/, 'https:'); try { ms.metadata = new MediaMetadata({ title: plain(cur.episode__title), artist: plain(cur.episode__feed__title), album: plain(cur.episode__feed__title), artwork: art ? [{src: art}] : [], }); } catch (e) {} const on = (name, fn) => { try { ms.setActionHandler(name, fn); } catch (e) {} }; on('play', () => resume()); on('pause', () => pause()); on('stop', () => pause()); on('seekbackward', () => skip(false)); on('seekforward', () => skip(true)); on('seekto', d => { if (d && d.seekTime != null) seek(d.seekTime * 1000); }); on('nexttrack', () => next()); on('previoustrack', null); sessionFor = cur.episode__id; } let toastTimer = 0; function toast(text) { let t = document.getElementById('nui-radio-toast'); if (!t) { t = document.createElement('div'); t.id = 'nui-radio-toast'; document.body.appendChild(t); } t.textContent = text; t.classList.add('nui-show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); } // ---- the host API (Bridge.kt's podcast part) ------------------------------------------- const ids = json => (parse(json, []) || []).map(Number); const sent = fn => function () { fn.apply(null, arguments); flush(); }; window.DioraPodcastHost = { podcastState: () => JSON.stringify(state), podcastRefresh(fetchFeeds) { refresh(!!fetchFeeds).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); }); }, podcastFeedRemoved() { refresh(false).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); }); }, podcastFavoriteChanged() {}, podcastEnqueue: sent((json, where, currentId) => enqueue(parse(json, []) || [], where || null, currentId > 0 ? currentId : null)), podcastDequeue: sent(id => dequeueAll([id])), podcastDequeueAll: sent(json => dequeueAll(ids(json))), podcastMove: sent((id, index) => move(id, index)), podcastReorder: sent(json => reorder(ids(json))), podcastMarkPlayed(id, played) { markPlayedAll([id], !!played, prefs.remove_played); // "Unplayed" brings it back into Neu on the server; fetch that. if (played) flush(); else refresh(false); }, podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)), podcastDismiss: sent(json => dismiss(ids(json))), podcastSettings: sent(json => setSettings(parse(json, {}) || {})), podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)), podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})), podcastFeedPrefs: sent((feedId, json) => feedPrefs(feedId, parse(json, {}) || {})), podcastPrefs: () => JSON.stringify(prefsJson()), podcastSetPref: (name, value) => setPref(name, value ? 1 : 0), podcastSetPrefValue: (name, value) => setPref(name, Number(value)), podcastPlay: id => playEpisode(id), // Play an episode; one that isn't queued yet is queued first (where the setting says). podcastPlayEpisode(json, currentId) { const ep = parse(json, {}) || {}; const id = ep.episode__id != null ? ep.episode__id : ep.id; if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null); playEpisode(id); flush(); }, podcastNext: () => next(), podcastToggle: () => (wantPlay ? pause() : resume()), podcastSeek: ms => seek(Number(ms)), podcastSkip: forward => skip(!!forward), podcastSpeed(speed) { setPref('speed', Number(speed)); applyKind(); setTimeout(report, 100); }, podcastSpeedForPodcast(speed) { const fid = curFeed(); if (fid) { setFeedSettings(fid, {playback_speed: speed > 0 ? Number(speed) : null}); flush(); } applyKind(); setTimeout(report, 100); }, playerState: () => JSON.stringify(playerJson()), podcastSleep(minutes) { if (minutes > 0) sleep.set(minutes, 0); else if (minutes < 0) sleep.set(0, -minutes); else sleep.set(0, 0); report(); }, podcastSleepExtend(minutes) { sleep.extend(minutes); report(); }, podcastSleepAuto(on) { prefs.sleep_auto = !!on; store('prefs', prefs); }, isEinkDevice: () => false, // No podcastDownloads: the browser streams; the page hides everything about downloads. }; // ---- the bottom sheet (native-ui.js's openSheet) ------------------------------------------ if (!window.DioraUI) { let sheetEl = null; const closeSheet = () => { if (!sheetEl) return false; sheetEl.remove(); sheetEl = null; return true; }; // items: {icon, label, hint, action, danger, active} | {custom: el} | falsy (skipped) const openSheet = (title, items) => { closeSheet(); const wrap = document.createElement('div'); wrap.id = 'nui-sheet'; wrap.innerHTML = '
'; 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 ------------------------------------------------------------------------- document.documentElement.classList.add('dpw'); for (const href of UI_STYLES) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); } // The dock the page puts its player bar in (native-radio.js builds it in the app): // here it is part of the radio bar at the bottom, in place of its "now playing", // so radio and podcasts share one bar. const dock = document.createElement('div'); dock.id = 'nui-dock'; const bar = document.getElementById('now-playing-bar'); if (bar) { bar.insertBefore(dock, bar.firstChild); document.documentElement.classList.add('dpw-in-bar'); } else { document.body.appendChild(dock); } // The page keeps clear of the bar, whose height changes with the screen. const placeDock = () => { const h = bar ? bar.getBoundingClientRect().height : 0; document.documentElement.style.setProperty('--dpw-bar-h', Math.round(h) + 'px'); }; placeDock(); if (bar && window.ResizeObserver) new ResizeObserver(placeDock).observe(bar); window.addEventListener('resize', placeDock); // Escape closes what the podcast page has open: a sheet, the player, notes, // a podcast, choosing several (its back handler) — never the tab itself. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || e.defaultPrevented) return; if (window.DioraUI.closeSheet && window.DioraUI.closeSheet()) return; const podTab = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts'; const open = document.getElementById('nui-pod-player') || (podTab && document.querySelector('#nui-pod .nui-pod-feedhead, #nui-pod .nui-pod-selbar')); if (open && window.__dioraBack) window.__dioraBack(); }); // The episode that was in the player before the page was left (not loaded until played). const was = load('player', null); if (was && was.entry && was.entry.episode__id != null) { cur = entry(was.entry.episode__id) || was.entry; lastPos = resumeMs(); } // Keep the place when the page goes away; send what's pending when the connection is back. const leaving = () => { if (cur && loaded && audio.readyState > 0) { saveProgress(cur.episode__id, Math.floor(posMs() / 1000)); if (navigator.sendBeacon) { navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify( {episode_id: cur.episode__id, position_seconds: Math.floor(posMs() / 1000)})); } } }; window.addEventListener('pagehide', leaving); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true); } else if (Date.now() - lastRefresh > 5 * 60000) { refresh(false); } }); window.addEventListener('online', () => { flush(); }); // The page (copied from the app) comes last, once all of the above is in place. const script = document.createElement('script'); script.src = UI_SCRIPT; document.body.appendChild(script); })();