// Copied from diora-android 339de8f 2026-10-02: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. // Podcasts for the phone, the AntennaPod way: the queue comes first. // // The app keeps the podcast state (Podcasts.kt: feeds, queue, inbox and // settings, fetched from the server in one request and kept for offline use) // and plays it (RadioService: the queue is the playlist, it keeps the place // and moves on by itself). This page only shows that state and hands what // the user does to DioraNative.podcast*. The web app's own podcast tab stays // hidden; its endpoints are the server side of all this. // // The browser runs this same file (diora-web copies it, tools/sync_podcast_ui.py): // there window.DioraPodcastHost (diora-web static/js/podcasts-web.js) plays the // part of the app, with the same calls. (function () { 'use strict'; const N = window.DioraPodcastHost || window.DioraNative; if (!N || !N.podcastState) return; // E-readers have no speaker; they stay a pure book app. if (N.isEinkDevice && N.isEinkDevice()) return; document.documentElement.classList.add('nui-podcasts'); const $id = id => document.getElementById(id); // The tabs; the search is a view of its own, opened from the top bar. const VIEWS = [ {id: 'queue', label: 'Warteschlange'}, {id: 'inbox', label: 'Neu'}, {id: 'episodes', label: 'Folgen'}, {id: 'feeds', label: 'Abos'}, ]; const VIEW_IDS = VIEWS.map(v => v.id).concat('search'); // The app's start page (this device; native.js opens it, the browser host too). const IN_APP = !window.DioraPodcastHost; const START_PAGES = [ !IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'}, {id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null}, {id: 'queue', label: 'Podcasts: Warteschlange'}, {id: 'inbox', label: 'Podcasts: Neu'}, {id: 'radio', label: 'Radio'}, ].filter(Boolean); function startPage() { let v = null; try { v = localStorage.getItem('nui_start_page'); } catch (e) {} return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id; } const POSITIONS = [ {value: 'back', label: 'Ans Ende'}, {value: 'front', label: 'An den Anfang'}, {value: 'after_current', label: 'Nach der laufenden Folge'}, ]; // The speed menu's choices while the account has no presets of its own (settings().speed_presets). const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2]; const MIN_SPEED = 0.5; const MAX_SPEED = 3.5; const MAX_PRESETS = 12; // Skip lengths back and forward (seconds), as AntennaPod offers them. const SKIPS = [5, 10, 15, 20, 30, 45, 60]; function parse(s, fallback) { try { return JSON.parse(s); } catch (e) { return fallback; } } let state = parse(N.podcastState(), {}); let player = {kind: null}; let view = 'queue'; let feedView = null; // {feed, episodes} while one podcast is open let searchResults = null; let preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing let previewSeq = 0; let refreshing = false; let selected = null; // Set of episode ids while choosing several (queue or inbox) let locked = false; // queue locked: no dragging try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {} try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {} // Opening into the podcasts (start page): that list. if (startPage() === 'queue' || startPage() === 'inbox') view = startPage(); if (!VIEW_IDS.includes(view)) view = 'queue'; const queue = () => state.queue || []; const inbox = () => state.inbox || []; // All new episodes: the server sends the first ones (inbox_limit) and how many there are // (inbox_total); what left „Neu“ here since then counts off. function inboxTotal() { const n = inbox().length; const total = state.inbox_total; if (typeof total !== 'number') return n; const sent = typeof state.inbox_limit === 'number' ? Math.min(total, state.inbox_limit) : n; return Math.max(n, total - Math.max(0, sent - n)); } const feeds = () => state.feeds || []; const settings = () => state.settings || {}; const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0); // The account's speed presets (null there: the default list). const presets = () => { const p = settings().speed_presets; return Array.isArray(p) && p.length ? p.slice().sort((a, b) => a - b) : SPEEDS; }; // Skipping to the next episode keeps the skipped one (AntennaPod's "keep skipped episodes"). const keepSkipped = () => settings().keep_skipped !== false; // "Zeiten an das Tempo anpassen": lengths and remaining times as long as they take at the speed. const bySpeed = () => !!settings().times_by_speed; let prefsSeen = null; let prefsAt = 0; function speedOf(feedId) { if (!prefsSeen || Date.now() - prefsAt > 2000) { prefsSeen = devicePrefs(); prefsAt = Date.now(); } const f = feedId ? feeds().find(x => x.id === feedId) : null; return f && f.playback_speed > 0 ? f.playback_speed : prefsSeen.speed > 0 ? prefsSeen.speed : 1; } // Seconds as shown: at the podcast's speed (or `speed`), when so set. function shown(seconds, feedId, speed) { if (!bySpeed()) return seconds; const s = speed > 0 ? speed : speedOf(feedId); return s > 0 ? seconds / s : seconds; } // ---- small helpers --------------------------------------------------------------- function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function button(label, title, onClick, cls) { const b = el('button', 'btn-icon nui-icon' + (cls ? ' ' + cls : ''), label); b.type = 'button'; b.title = title; b.setAttribute('aria-label', title); b.addEventListener('click', e => { e.stopPropagation(); onClick(e); }); return b; } function openSheet(title, items) { if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items); } function clock(seconds) { seconds = Math.max(0, Math.round(seconds || 0)); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function minutes(seconds) { const m = Math.round((seconds || 0) / 60); if (m < 60) return `${m} min`; return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`; } // Short dates: heute, gestern, vor 3 Tagen (up to 6), then 8.7. this year, 8.7.22 before. function day(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; const now = new Date(); const midnight = x => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const diff = Math.round((midnight(now) - midnight(d)) / 86400000); if (diff <= 0) return 'heute'; if (diff === 1) return 'gestern'; if (diff < 7) return `vor ${diff} Tagen`; const dm = `${d.getDate()}.${d.getMonth() + 1}.`; return d.getFullYear() === now.getFullYear() ? dm : dm + String(d.getFullYear() % 100).padStart(2, '0'); } let toastTimer = 0; function toast(text) { let t = $id('nui-radio-toast'); if (!t) { t = el('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); } // Feed titles sometimes arrive with HTML entities still in them (&). const decoder = document.createElement('textarea'); function plain(s) { if (!s || s.indexOf('&') < 0) return s || ''; decoder.innerHTML = s; return decoder.value; } // Episodes come in two shapes: queue entries (episode__*) and inbox/feed // episodes. One view of both: function ep(o) { const e = shape(o); e.title = plain(e.title); e.feed = plain(e.feed); return e; } function shape(o) { if (o.episode__id != null) { return { id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id, art: o.episode__artwork_url, feedArt: o.episode__feed__artwork_url || feedArtOf(o.episode__feed__id), duration: o.episode__duration_seconds, pos: o.position_seconds, date: o.episode__pub_date, raw: o, }; } return { id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id, art: o.artwork_url, feedArt: o.feed__artwork_url || feedArtOf(o.feed__id) || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds, pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o, }; } function feedArtOf(id) { const f = id ? feeds().find(x => x.id === id) : null; return f ? f.artwork_url : ''; } // A cover: the episode's own, else its podcast's, else a tile with the podcast's initial. function art(src, size, fallback, name) { const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : '')); const srcs = [src, fallback].filter(Boolean).map(s => s.replace(/^http:/, 'https:')) .filter((s, i, all) => all.indexOf(s) === i); const initial = () => { if (box.querySelector('img') || box.firstChild) return; const letter = (plain(name || '').replace(/^(the|der|die|das)\s+/i, '').match(/[0-9A-Za-zÀ-ÿ]/) || [''])[0]; if (letter) box.append(el('span', 'nui-pod-initial', letter.toUpperCase())); }; const next = () => { const s = srcs.shift(); if (!s) { initial(); return; } const img = el('img'); img.loading = 'lazy'; img.alt = ''; img.src = s; img.addEventListener('error', () => { img.remove(); next(); }); box.append(img); }; next(); return box; } function inQueue(id) { return queue().some(q => q.episode__id === id); } // ---- actions --------------------------------------------------------------------------- function play(e) { const r = remote(); if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); return; } N.podcastPlayEpisode(JSON.stringify(e.raw), currentId()); } function enqueue(e, where) { N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId()); const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position)); toast('In der Warteschlange' + (label ? ': ' + label.label.toLowerCase() : '')); } // Move within the queue: "next" is right after the episode that plays. function moveTo(e, where) { const ids = queue().map(q => q.episode__id).filter(id => id !== e.id); let index = where === 'top' ? 0 : ids.length; if (where === 'next') { const at = ids.indexOf(currentId()); index = at >= 0 ? at + 1 : 0; } N.podcastMove(e.id, index); } function markPlayed(e) { N.podcastMarkPlayed(e.id, true); toast('Als gehört markiert'); } // Choosing several, from a row's menu (long press or ⋯). const selectItem = (e, opts) => opts && opts.select && {icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)}; function queueMenu(e, opts) { const playing = currentId() === e.id; openSheet(e.title, [ {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)}, !playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, favoriteItem(e), !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, {icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)}, ...dlMenuItems(e), {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)}, selectItem(e, opts), ]); } function episodeMenu(e, opts) { const queued = inQueue(e.id); openSheet(e.title, [ {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, favoriteItem(e), !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')}, !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')}, queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)}, !e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)}, e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)}, ...dlMenuItems(e), {icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen', action: () => N.podcastDismiss(JSON.stringify([e.id]))}, selectItem(e, opts), ]); } function settingsMenu() { const s = settings(); const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({ icon: s[name] === p.value ? '●' : '○', label: p.label, active: s[name] === p.value, action: () => N.podcastSettings(JSON.stringify({[name]: p.value})), }))); const label = v => (POSITIONS.find(p => p.value === v) || POSITIONS[0]).label; const prefs = devicePrefs(); // Grouped like AntennaPod's settings; each group opens its own list. const group = (icon, title, hint, items) => ({icon, label: title, hint, action: () => openSheet(title, items())}); openSheet('Podcasts', [ {icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, hier kommt nur das Ergebnis an', action: () => refresh(true)}, group('≡', 'Warteschlange', 'wohin neue Folgen kommen, gehörte entfernen', () => [ {icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position), action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')}, {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')}, removePlayedItem(), keepSkippedItem(), deleteDequeueItem(), ]), group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“', 'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer', 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [ skipItem('skip_back', 'Zurückspringen', SKIPS), skipItem('skip_forward', 'Vorspringen', SKIPS), 'speed_presets' in s && {icon: '»', label: 'Tempo-Voreinstellungen', hint: presets().map(speedLabel).join(' · '), action: () => presetsEditor()}, silenceItem(), playedItem(), timesItem(), headsetItem(), notifyItem(), ]), mobileGroup(), DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads}, DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [ // Account-wide (server): new subscriptions start with auto-download on or off. 'auto_download_default' in s && {icon: s.auto_download_default ? '☑' : '☐', label: 'Neue Abos laden von selbst', hint: s.auto_download_default ? 'an: neue Folgen neuer Abos werden geladen' : 'aus: pro Podcast einschaltbar', action: () => N.podcastSettings(JSON.stringify({auto_download_default: !s.auto_download_default}))}, ...dlSettingsItems(), ]), backgroundSyncItem(), {icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings}, startPageItem(), group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml}, {icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml}, ]), {icon: '▤', label: 'Hörstatistik', action: openStats}, ]); } // The app's background job (BackgroundSync.kt): new episodes, their notifications and downloads with the app closed. const BG_SYNC = [ {value: 0, label: 'Aus', hint: 'nur wenn die App offen ist'}, {value: 15, label: 'Alle 15 Minuten'}, {value: 60, label: 'Jede Stunde', hint: 'Standard'}, {value: 240, label: 'Alle 4 Stunden'}, ]; function backgroundSyncItem() { if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null; const now = N.podcastBackgroundSync(); const label = (BG_SYNC.find(c => c.value === now) || BG_SYNC[2]).label; return {icon: '⟲', label: 'Im Hintergrund abgleichen', hint: label + ' · neue Folgen, Mitteilungen, Downloads', action: () => openSheet('Im Hintergrund abgleichen', BG_SYNC.map(c => ({ icon: c.value === now ? '●' : '○', label: c.label, hint: c.hint, active: c.value === now, action: () => { N.podcastSetBackgroundSync(c.value); settingsMenu(); }, })))}; } // Skip lengths and "counts as played" belong to the account where the server knows them // (the host sends them there, PlaybackPrefs.set); else to this device. const where = name => (name in settings() ? 'für alle Geräte' : 'auf diesem Gerät'); function skipItem(name, label, choices) { if (!N.podcastSetPrefValue) return null; const now = devicePrefs()[name]; return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`, action: () => openSheet(`${label} (${where(name)})`, choices.map(c => ({ icon: c === now ? '●' : '○', label: `${c} Sekunden`, active: c === now, action: () => { N.podcastSetPrefValue(name, c); toast(`${label}: ${c} Sekunden`); }, })))}; } function playedItem() { if (!N.podcastSetPrefValue) return null; const now = devicePrefs().smart_played; if (now == null) return null; const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende'); return {icon: '✓', label: 'Als gehört zählen', hint: label(now), action: () => openSheet(`Eine Folge zählt als gehört … (${where('smart_played')})`, [0, 15, 30, 45, 60, 120, 300, 600].map(v => ({ icon: v === now ? '●' : '○', label: label(v), active: v === now, action: () => N.podcastSetPrefValue('smart_played', v), })))}; } // An account setting (server: POST /podcasts/settings/), through the host. function setAccount(name, value, text) { if (!N.podcastSettings) return; N.podcastSettings(JSON.stringify({[name]: value})); if (text) toast(text); } function accountSwitch(name, label, onHint, offHint, def) { const s = settings(); if (!(name in s)) return null; const on = s[name] == null ? def : !!s[name]; return {icon: on ? '☑' : '☐', label, hint: on ? onHint : offHint, action: () => setAccount(name, !on, `${label}: ${on ? 'aus' : 'an'}`)}; } // AntennaPod's "keep skipped episodes": ⏭, "Folge überspringen", headset and car go on without marking. function keepSkippedItem() { return accountSwitch('keep_skipped', 'Übersprungene Folgen behalten', 'an: wer zur nächsten springt, lässt die Folge ungehört in der Warteschlange', 'aus: übersprungene Folgen zählen als gehört', true); } // AntennaPod's "delete removes from queue". function deleteDequeueItem() { if (!DL) return null; return accountSwitch('delete_dequeue', 'Löschen nimmt aus der Warteschlange', 'an: wer einen Download löscht, nimmt die Folge auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true); } // AntennaPod's "adjust media info to playback speed". function timesItem() { return accountSwitch('times_by_speed', 'Zeiten an das Tempo anpassen', 'an: Längen und Restzeiten so, wie lange sie beim eingestellten Tempo dauern', 'aus: Längen wie bei 1×', false); } // What may use mobile data: downloads by hand (account), automatic ones (this device), streaming (account). const STREAMING = [ {value: 'allow', label: 'Erlauben'}, {value: 'ask', label: 'Nachfragen', hint: 'einmal pro Folge'}, {value: 'never', label: 'Nie', hint: 'nur WLAN oder heruntergeladene Folgen'}, ]; function mobileGroup() { const s = settings(); const items = () => { const now = settings(); const dls = DL ? parse(N.podcastDownloadSettings(), dlSettings) : {}; const streaming = STREAMING.find(c => c.value === now.mobile_streaming) || STREAMING[0]; return [ DL && accountSwitch('mobile_downloads', 'Downloads per Hand über mobile Daten', 'an: was du von Hand lädst, lädt auch unterwegs', 'aus: wartet aufs WLAN, wie automatische Downloads', true), DL && {icon: dls.mobile ? '☑' : '☐', label: 'Automatische Downloads über mobile Daten', hint: (dls.mobile ? 'an' : 'aus: nur im WLAN') + ' · dieses Gerät', action: () => dlSet({mobile: !dls.mobile})}, N.podcastAllowStream && 'mobile_streaming' in now && {icon: '⇵', label: 'Streamen über mobile Daten', hint: streaming.label + ' · Radio ist davon nicht betroffen', action: () => openSheet('Folgen über mobile Daten streamen', STREAMING.map(c => ({ icon: c.value === streaming.value ? '●' : '○', label: c.label, hint: c.hint || null, active: c.value === streaming.value, action: () => setAccount('mobile_streaming', c.value, `Streamen über mobile Daten: ${c.label}`), })))}, ].filter(Boolean); }; if (!('mobile_downloads' in s || 'mobile_streaming' in s) || !items().length) return null; return {icon: '◢', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())}; } // Which page the app opens with (this device). function startPageItem() { const now = startPage(); const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label; return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'), action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP || document.documentElement.classList.contains('nui-radio')).map(p => ({ icon: p.id === now ? '●' : '○', label: p.label, hint: p.hint || null, active: p.id === now, action: () => { try { localStorage.setItem('nui_start_page', p.id); } catch (e) {} toast('Startseite: ' + p.label); }, })))}; } // ---- speed presets (the account's; AntennaPod's speed presets, up to 3.5×) ---- const roundSpeed = v => Math.round(v * 100) / 100; function savePresets(list) { setAccount('speed_presets', list); setTimeout(() => presetsEditor(list || SPEEDS), 60); } function presetsEditor(list) { const now = (list || presets()).slice().sort((a, b) => a - b); const own = Array.isArray(settings().speed_presets) || !!list; openSheet('Tempo-Voreinstellungen', now.map(v => ({ icon: '✕', label: speedLabel(v), hint: now.length > 1 ? 'antippen zum Entfernen' : 'eine bleibt immer', action: () => { if (now.length > 1) savePresets(now.filter(x => x !== v)); else toast('Eine Voreinstellung bleibt immer'); }, })).concat([ {icon: '+', label: 'Tempo hinzufügen', hint: `${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`, action: () => addPreset(now)}, own && {icon: '↺', label: 'Standardliste', hint: SPEEDS.map(speedLabel).join(' · '), action: () => savePresets(null)}, ].filter(Boolean))); } function addPreset(now) { if (now.length >= MAX_PRESETS) { toast(`Höchstens ${MAX_PRESETS} Voreinstellungen`); return; } const add = v => savePresets(now.concat(roundSpeed(v)).sort((a, b) => a - b)); const choices = []; for (let v = MIN_SPEED; v <= MAX_SPEED + 0.001; v += 0.25) { if (!now.some(x => Math.abs(x - v) < 0.001)) choices.push(roundSpeed(v)); } openSheet('Tempo hinzufügen', choices.map(v => ({icon: '+', label: speedLabel(v), action: () => add(v)})) .concat(typeof customPrompt === 'function' ? [{icon: '✎', label: 'Eigener Wert …', hint: 'z. B. 1,65', action: async () => { const t = await customPrompt(`Tempo (${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}):`, ''); if (t == null) return; const v = roundSpeed(parseFloat(String(t).replace(',', '.').replace(/[×x]/g, ''))); if (!(v >= MIN_SPEED && v <= MAX_SPEED)) { toast(`Bitte eine Zahl von ${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`); return; } if (now.some(x => Math.abs(x - v) < 0.001)) { toast(`${speedLabel(v)} ist schon dabei`); return; } add(v); }}] : [])); } // A host without headset buttons or silence skipping (the browser) leaves them out of its prefs. function headsetItem() { if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null; const next = !!devicePrefs().headset_next; return {icon: '🎧', label: 'Kopfhörer: zweimal tippen', hint: next ? 'nächste Folge' : 'vorspulen (dreimal: zurück)', action: () => openSheet('Kopfhörer zweimal tippen …', [ {icon: !next ? '●' : '○', label: 'Vorspulen', hint: 'dreimal tippen springt zurück', action: () => N.podcastSetPref('headset_next', false)}, {icon: next ? '●' : '○', label: 'Nächste Folge', action: () => N.podcastSetPref('headset_next', true)}, ])}; } // The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's. const NOTIFY = [ {key: 'next', label: 'Folge überspringen', hint: () => (keepSkipped() ? 'die nächste spielt, diese bleibt ungehört eingereiht' : 'zählt als gehört, die nächste spielt')}, {key: 'chapter', label: 'Nächstes Kapitel', hint: () => 'wenn die Folge Kapitel hat'}, // The account's presets; without any, the app's own list. {key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length ? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')}, {key: 'sleep', label: 'Sleep-Timer', hint: () => '15 Minuten an / aus'}, ]; function notifyItem() { if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null; const now = devicePrefs().notify_buttons; const names = keys => keys.map(k => (NOTIFY.find(n => n.key === k) || {}).label).filter(Boolean).join(', ') || 'keine'; const sheet = chosen => openSheet('Zwei Knöpfe in der Benachrichtigung', NOTIFY.map(n => ({ icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint(), // A third one replaces the one chosen first. action: () => { const next = chosen.includes(n.key) ? chosen.filter(k => k !== n.key) : chosen.concat(n.key).slice(-2); N.podcastSetNotifyButtons(next.join(',')); toast('Benachrichtigung: ' + names(next)); }, }))); return {icon: '⊞', label: 'Knöpfe in der Benachrichtigung', hint: names(now), action: () => sheet(now)}; } function silenceItem() { if (!N.podcastSetPref || !('skip_silence' in devicePrefs())) return null; const on = !!devicePrefs().skip_silence; return {icon: on ? '☑' : '☐', label: 'Stille überspringen', hint: on ? 'an: Pausen in Folgen werden übersprungen' : 'aus', action: () => N.podcastSetPref('skip_silence', !on)}; } function devicePrefs() { return parse(N.podcastPrefs ? N.podcastPrefs() : '{}', {}); } function removePlayedItem() { if (!N.podcastSetPref) return null; const on = devicePrefs().remove_played !== false; return {icon: on ? '☑' : '☐', label: 'Gehörte Folgen aus der Warteschlange nehmen', hint: on ? 'an: fertige Folgen verlassen die Warteschlange' : 'aus: fertige Folgen bleiben stehen', action: () => { N.podcastSetPref('remove_played', !on); toast(on ? 'Gehörte Folgen bleiben jetzt stehen' : 'Gehörte Folgen verlassen jetzt die Warteschlange'); }}; } // ---- sorting, locking, choosing several ---------------------------------------------- const SORTS = [ {id: 'new', label: 'Neueste zuerst', key: e => -(Date.parse(e.date) || 0)}, {id: 'old', label: 'Älteste zuerst', key: e => Date.parse(e.date) || 0}, {id: 'short', label: 'Kürzeste zuerst', key: e => Math.max(0, (e.duration || 0) - (e.pos || 0))}, {id: 'long', label: 'Längste zuerst', key: e => -Math.max(0, (e.duration || 0) - (e.pos || 0))}, {id: 'feed', label: 'Nach Podcast', key: e => (e.feed || '').toLowerCase() + ' ' + String(9e15 - (Date.parse(e.date) || 0))}, ]; // The episode that plays stays on top; everything else is sorted. function sortQueue(sort) { const items = queue().map(ep); const now = items.filter(e => e.id === currentId()); const rest = items.filter(e => e.id !== currentId()); if (sort === 'shuffle') { for (let i = rest.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [rest[i], rest[j]] = [rest[j], rest[i]]; } } else { const s = SORTS.find(x => x.id === sort); rest.sort((a, b) => { const ka = s.key(a), kb = s.key(b); return ka < kb ? -1 : ka > kb ? 1 : 0; }); } N.podcastReorder(JSON.stringify(now.concat(rest).map(e => e.id))); } const keepSorted = () => settings().keep_sorted || ''; function sortMenu() { const kept = keepSorted(); const keptLabel = kept ? (SORTS.find(x => x.id === kept) || {}).label : ''; openSheet('Warteschlange sortieren', SORTS.map(x => ({icon: '⇅', label: x.label, action: () => sortQueue(x.id)})) .concat([{icon: '⤨', label: 'Mischen', action: () => sortQueue('shuffle')}]) .concat('keep_sorted' in settings() ? [{icon: kept ? '☑' : '☐', label: 'Immer sortiert halten', hint: kept ? `an: ${keptLabel}` : 'aus: Reihenfolge bestimmst du', action: () => openSheet('Immer sortiert halten', [{id: '', label: 'Aus'}].concat(SORTS).map(x => ({ icon: kept === x.id ? '●' : '○', label: x.label, hint: x.id ? 'auch neu eingereihte Folgen' : null, action: () => N.podcastSettings(JSON.stringify({keep_sorted: x.id})), })))}] : [])); } function toggleLock() { locked = !locked; try { localStorage.setItem('nui_pod_locked', locked ? '1' : '0'); } catch (e) {} toast(locked ? 'Warteschlange gesperrt' : 'Warteschlange entsperrt'); render(); } function startSelecting(id) { commitSwipe(); selected = new Set(id ? [id] : []); render(); } function stopSelecting() { if (!selected) return false; selected = null; render(); return true; } function toggleSelected(id) { if (selected.has(id)) selected.delete(id); else selected.add(id); render(); } // The bar while choosing: count, all/none, and what to do with the chosen ones. function selectionBar(items, actions) { const bar = el('div', 'nui-pod-selbar'); const all = items.length > 0 && items.every(e => selected.has(e.id)); bar.append( button('✕', 'Auswahl beenden', stopSelecting), el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : 'Folgen antippen'), button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => { selected = new Set(all ? [] : items.map(e => e.id)); render(); }), ); const acts = el('div', 'nui-pod-selactions'); for (const a of actions.filter(Boolean)) { const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label); b.type = 'button'; b.disabled = !selected.size; b.addEventListener('click', () => { const ids = items.filter(e => selected.has(e.id)).map(e => e.id); if (ids.length) a.run(ids); }); acts.append(b); } bar.append(acts); return bar; } function listTools(buttons) { const t = el('div', 'nui-pod-tools'); for (const b of buttons) if (b) t.append(b); return t; } const nEpisodes = n => `${n} ${n === 1 ? 'Folge' : 'Folgen'}`; // Choosing several in a list of episodes (Folgen, one podcast): what to do with them. function episodeSelection(items) { const pick = ids => items.filter(e => ids.includes(e.id)); const fresh = new Set(inbox().map(o => o.id)); return selectionBar(items, [ {label: 'Einreihen', run: ids => enqueueMany(pick(ids))}, {label: 'Gehört', run: ids => markMany(pick(ids), true)}, {label: 'Ungehört', run: ids => markMany(pick(ids), false)}, DL && {label: 'Laden', run: ids => downloadMany(pick(ids))}, {label: 'Favorit', run: ids => openSheet(nEpisodes(ids.length), [ {icon: '★', label: 'Als Favorit markieren', action: () => favoriteMany(pick(ids), true)}, {icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)}, ])}, // Only where some of them are new. items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', run: ids => { const gone = ids.filter(id => fresh.has(id)); if (gone.length) N.podcastDismiss(JSON.stringify(gone)); toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“'); stopSelecting(); }}, ].filter(Boolean)); } function enqueueMany(list) { const todo = list.filter(e => !inQueue(e.id)); if (!todo.length) { toast('Schon alle in der Warteschlange'); stopSelecting(); return; } const add = where => { N.podcastEnqueue(JSON.stringify(todo.map(e => e.raw)), where, currentId()); for (const e of todo) e.raw.in_queue = true; toast(`${nEpisodes(todo.length)} eingereiht`); stopSelecting(); }; openSheet(`${nEpisodes(todo.length)} einreihen`, [ {icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label, action: () => add('')}, {icon: '⤴', label: 'Als Nächstes', action: () => add('after_current')}, {icon: '⇈', label: 'An den Anfang', action: () => add('front')}, {icon: '⇊', label: 'Ans Ende', action: () => add('back')}, ]); } function markMany(list, played) { const ids = list.map(e => e.id); if (played) { N.podcastMarkPlayedAll(JSON.stringify(ids), true); } else { // "Ungehört" one by one fetches the state again each time: all but the // last in one go; downloaded ones singly, the app has to know of them. const single = ids.filter(id => DL && downloads[id]); const rest = ids.filter(id => !(DL && downloads[id])); if (!single.length) single.push(rest.pop()); if (rest.length) N.podcastMarkPlayedAll(JSON.stringify(rest), false); for (const id of single) N.podcastMarkPlayed(id, false); } for (const e of list) { e.raw.played = played; if (played) e.raw.position_seconds = 0; } toast(`${nEpisodes(ids.length)} als ${played ? 'gehört' : 'ungehört'} markiert`); stopSelecting(); } function downloadMany(list) { const todo = list.filter(e => (e.raw.audio_url || e.raw.episode__audio_url) && !(downloads[e.id] && downloads[e.id].state !== 'failed')); const waiting = {}; for (const e of todo) { N.podcastDownload(JSON.stringify(e.raw)); waiting[e.id] = {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'}; } toast(todo.length ? `${nEpisodes(todo.length)} ${todo.length === 1 ? 'wird' : 'werden'} geladen` : 'Schon alle geladen'); selected = null; window.__dioraDownloads(Object.assign({}, downloads, waiting)); render(); } async function favoriteMany(list, star) { stopSelecting(); try { const r = await fetch('/podcasts/episodes/favorite/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({episode_ids: list.map(e => e.id), favorite: star}), }); if (!r.ok) throw new Error(r.status); for (const e of list) { e.raw.favorite = star; if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, star); const cached = storedDetails(e.id); if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: star})); } toast(star ? `★ ${nEpisodes(list.length)} als Favorit markiert` : `${nEpisodes(list.length)}: kein Favorit mehr`); } catch (err) { toast(navigator.onLine ? 'Favoriten ließen sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); } render(); } // ---- filters and order of episode lists (Folgen, one podcast) ------------------------- const FILTERS = [ {id: 'unplayed', label: 'Ungehört', test: o => !o.played, none: 'Keine ungehörten Folgen.'}, {id: 'in_progress', label: 'Angefangen', test: o => !o.played && (o.position_seconds || 0) > 0, none: 'Keine angefangenen Folgen.'}, {id: 'favorites', label: 'Favoriten', test: o => !!o.favorite, none: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.'}, {id: 'queued', label: 'Warteschlange', test: o => inQueue(o.id), none: 'Keine davon steht in der Warteschlange.'}, {id: 'downloaded', label: 'Geladen', app: true, test: o => dlDone(o.id), none: 'Keine Folgen auf dem Gerät.'}, {id: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0, none: 'Noch nichts gehört.'}, {id: 'played', label: 'Gehört', test: o => !!o.played, none: 'Noch keine gehörten Folgen.'}, ]; // Filters that can't both be on: choosing one takes the others off. const EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']}; const EP_SORTS = [ {id: 'new', label: 'Neueste zuerst', key: o => -(Date.parse(o.pub_date) || -Infinity)}, {id: 'old', label: 'Älteste zuerst', key: o => Date.parse(o.pub_date) || Infinity}, {id: 'long', label: 'Längste zuerst', key: o => (o.duration_seconds ? -o.duration_seconds : Infinity)}, {id: 'short', label: 'Kürzeste zuerst', key: o => o.duration_seconds || Infinity}, {id: 'title', label: 'Nach Titel', key: o => plain(o.title).toLocaleLowerCase('de')}, {id: 'played', label: 'Zuletzt gehört', all: true}, ]; const sortLabel = id => (EP_SORTS.find(s => s.id === id) || EP_SORTS[0]).label; function dlDone(id) { return DL && !!downloads[id] && downloads[id].state === 'done'; } function matchesAll(o, filters) { return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); }); } function sortEpisodes(list, sort) { const s = EP_SORTS.find(x => x.id === sort); if (!s || !s.key) return list; const keyed = list.map((o, i) => [s.key(o), i, o]); keyed.sort((a, b) => (typeof a[0] === 'string' ? a[0].localeCompare(b[0], 'de') : a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) || a[1] - b[1]); return keyed.map(k => k[2]); } // One row of chips: "Alle" and the filters, combinable. function filterRow(active, onChange, allEpisodes) { const chips = el('div', 'nui-pod-filters'); const chip = (label, on, onClick) => { const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''), label); b.type = 'button'; b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.addEventListener('click', onClick); chips.append(b); }; chip('Alle', !active.length, () => onChange([])); for (const f of FILTERS) { if ((f.app && !DL) || (f.all && !allEpisodes)) continue; const on = active.includes(f.id); chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id) : active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id))); } // An active filter further right shows (the row scrolls sideways). requestAnimationFrame(() => { const on = chips.querySelector('.nui-pod-chip.on'); if (on && on.offsetLeft + on.offsetWidth > chips.scrollLeft + chips.clientWidth) chips.scrollLeft = on.offsetLeft - 8; }); return chips; } function sortMenuFor(current, allEpisodes, onPick) { openSheet('Folgen sortieren', EP_SORTS.filter(s => allEpisodes || !s.all).map(s => ({ icon: s.id === current ? '●' : '○', label: s.label, active: s.id === current, action: () => onPick(s.id), }))); } function noneText(filters) { const f = filters.length === 1 && FILTERS.find(x => x.id === filters[0]); return f ? f.none : filters.length ? 'Keine Folgen mit diesen Filtern.' : 'Keine Folgen.'; } function refresh(fetchFeeds) { refreshing = true; render(); N.podcastRefresh(!!fetchFeeds); } // ---- rendering --------------------------------------------------------------------------- let root = null; // The page scrolls inside .main-content (app.css), not the window. function scroller() { for (let e = root && root.parentElement; e && e !== document.body; e = e.parentElement) { const o = getComputedStyle(e).overflowY; if (o === 'auto' || o === 'scroll') return e; } return document.scrollingElement || document.documentElement; } function scrollTop(y) { const s = scroller(); if (y == null) return s.scrollTop; s.scrollTop = y; return y; } function build() { const tab = $id('tab-podcasts'); if (!tab || $id('nui-pod')) return false; root = el('div'); root.id = 'nui-pod'; tab.prepend(root); return true; } function render() { if (!root) return; const scroll = scrollTop(); // Search results arriving while typing: the field keeps its text and the cursor. const q = $id('nui-pod-q'); const typing = q && document.activeElement === q ? [q.selectionStart, q.selectionEnd] : null; root.textContent = ''; const bar = el('div', 'nui-tab-bar'); bar.id = 'nui-pod-bar'; const text = el('div', 'nui-bar-text'); text.append(el('span', 'native-brand', 'Podcasts')); if (refreshing) text.append(el('span', 'muted nui-pod-status', 'Wird abgerufen …')); else if (state.server_time) text.append(el('span', 'muted nui-pod-status', 'Stand ' + day(state.server_time) + ', ' + new Date(state.server_time).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}))); bar.append(...[text, dlIndicator(), searchButton(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)), button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean)); root.append(bar); if (preview) { renderPreview(); } else if (feedView) { renderFeed(); } else { const tabs = el('div', 'nui-pod-tabs'); for (const v of VIEWS) { const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : '')); b.type = 'button'; b.append(el('span', null, v.label)); const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inboxTotal() : 0; if (count) b.append(el('span', 'nui-pod-count' + (v.id === 'queue' ? ' nui-pod-count-queue' : ''), count > 999 ? '999+' : String(count))); b.addEventListener('click', () => { selected = null; view = v.id; try { localStorage.setItem('nui_pod_view', view); } catch (e) {} render(); scrollTop(0); }); tabs.append(b); } root.append(tabs); const offer = importOffer(); if (offer) root.append(offer); if (view === 'queue') renderQueue(); else if (view === 'inbox') renderInbox(); else if (view === 'episodes') renderEpisodes(); else if (view === 'feeds') renderFeeds(); else renderSearch(); } scrollTop(scroll); const q2 = typing && $id('nui-pod-q'); if (q2) { q2.focus(); try { q2.setSelectionRange(typing[0], typing[1]); } catch (e) {} } } // The search: a magnifier in the top bar (an SVG: the text glyphs for it differ from font to font). function searchButton() { const b = button('', 'Suchen', () => { selected = null; feedView = null; preview = null; view = 'search'; render(); scrollTop(0); const q = $id('nui-pod-q'); if (q && !(searchResults && searchResults.q)) q.focus(); }, 'nui-pod-searchbtn' + (view === 'search' && !feedView && !preview ? ' on' : '')); b.innerHTML = ''; return b; } // AntennaPod had episodes on the phone: offered once to fetch them again (Wi-Fi only). function importOffer() { const o = state.import_downloads; if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null; const box = el('div', 'nui-pod-offer'); const s = parse(N.podcastDownloadSettings(), {}); const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? (s.count === 0 ? queue().length : s.count || 0) : 0); const raise = s.limit > 0 && needed > s.limit ? ` „Höchstens behalten“ geht dafür von ${s.limit} auf ${[5, 10, 25, 50, 100, 500].find(n => n >= needed) || 'unbegrenzt'}.` : ''; box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` + (o.bytes ? ` (${size(o.bytes)})` : '') + '. Hier noch einmal laden? Nur im WLAN; sie kommen nicht in die Warteschlange.' + raise)); const row = el('div', 'nui-pod-offer-row'); row.append(button('Laden', 'Im WLAN herunterladen', () => { N.podcastImportDownloads(true); box.remove(); }, 'btn'), button('Nein danke', 'Nicht laden', () => { N.podcastImportDownloads(false); box.remove(); }, 'btn')); box.append(row); return box; } window.__dioraImportDownloads = function (r) { if (!r || !r.ok) toast('Die Liste aus AntennaPod kam nicht an — später noch einmal'); else if (r.count) toast(`${r.count} ${r.count === 1 ? 'Folge wird' : 'Folgen werden'} im WLAN geladen` + (r.limit != null ? ` · Höchstens behalten: ${r.limit || 'unbegrenzt'}` : '')); render(); }; function empty(text) { root.append(el('div', 'nui-pod-empty', text)); } function progressBar(pos, duration) { if (!pos || !duration) return null; const b = el('div', 'nui-pod-progress'); const f = el('span'); f.style.width = Math.min(100, Math.round(pos / duration * 100)) + '%'; b.append(f); return b; } // One episode in a list. opts.list names the list (queue, inbox, episodes, feed, downloads): // it decides the one action at hand and the swipes; everything else is in the row's menu // (⋯ or a long press). opts.filters: the list's filters (what a swipe takes out of it). function episodeRow(e, opts) { const list = opts.list || ''; const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : '')); row.dataset.id = e.id; if (swipeGone(e.id)) row.classList.add('nui-pod-gone'); row.append(art(e.art, null, e.feedArt, e.feed)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', e.title)); // Length first: podcast names are long and get cut off. const meta = []; // A played episode, or a place past the feed's (often wrong) length: just the length. const started = e.pos > 30 && !e.played && e.pos < e.duration - 30; if (e.duration) meta.push(started ? minutes(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId))); if (opts.showDate && e.date) meta.push(day(e.date)); if (opts.showFeed && e.feed) meta.push(e.feed); const metaEl = el('div', 'nui-pod-meta', meta.join(' · ')); // Where the row's button isn't the download: its state as a small mark. const mark = selected || dlPrimary(e, list) ? null : dlMark(e); if (mark) metaEl.prepend(mark); main.append(metaEl); const p = e.played ? null : progressBar(e.pos, e.duration); if (p) main.append(p); row.append(main); if (selected) { row.classList.add('nui-pod-selecting'); const box = el('span', 'nui-pod-check' + (selected.has(e.id) ? ' on' : ''), selected.has(e.id) ? '☑' : '☐'); row.prepend(box); row.addEventListener('click', () => toggleSelected(e.id)); return row; } const menu = () => (list === 'queue' ? queueMenu(e, {select: !opts.noSelect}) : episodeMenu(e, {select: !opts.noSelect})); row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu')); row.addEventListener('click', () => play(e)); // Swipes: touch only, not while the queue is locked. const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : ''; rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes); return row; } // The one action at hand: the queue and the downloads play, „Neu“ queues, // the episode lists download (or queue, where nothing downloads). function dlPrimary(e, list) { return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url); } function primaryButton(e, list) { if (list === 'queue' || list === 'downloads') { const now = currentId() === e.id; const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen', () => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn'); b.dataset.play = e.id; return b; } if (dlPrimary(e, list)) return dlButton(e); const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue); return queued ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') : button('+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; render(); }); } // ▶ / ❚❚ in the rows follow the player without drawing the lists again. function playButtons() { document.querySelectorAll('#nui-pod [data-play]').forEach(b => { const now = currentId() === Number(b.dataset.play) && player.playing; b.textContent = now ? '❚❚' : '▶'; b.title = now ? 'Pause' : 'Abspielen'; b.setAttribute('aria-label', b.title); }); } // ---- long press and swipes on a row --------------------------------------------------- // Long press opens the row's menu. Swiping (a finger, not a mouse) left or right does what // the list's swipe settings say, with 4 s to undo. Vertical moves stay the page's scrolling; // the queue's drag handle and the sideways chip rows aren't rows, so nothing competes. function rowGestures(row, onLong, onSwipe, list) { let start = null; let timer = 0; let mode = ''; let dx = 0; let vx = 0; let last = null; let bg = null; let swallow = false; const stopTimer = () => { if (timer) clearTimeout(timer); timer = 0; }; const actionFor = d => swipeAction(list, d); const limit = () => Math.min(140, Math.max(72, row.offsetWidth * 0.3)); const armed = () => { const act = actionFor(dx < 0 ? 'left' : 'right'); if (act === 'none') return false; const fling = Math.abs(dx) > 40 && Math.abs(vx) > 0.6 && Math.sign(vx) === Math.sign(dx); return Math.abs(dx) >= limit() || fling; }; const paint = () => { const d = dx < 0 ? 'left' : 'right'; const act = actionFor(d); const shownDx = act === 'none' ? dx * 0.25 : dx; row.style.transform = `translateX(${shownDx}px)`; const a = SWIPE_ACTIONS.find(x => x.id === act) || SWIPE_ACTIONS[0]; bg.dataset.dir = d; bg.classList.toggle('armed', armed()); const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r'); label.textContent = act === 'none' ? '' : (d === 'right' ? `${a.icon} ${swipeLabel(a, row)}` : `${swipeLabel(a, row)} ${a.icon}`); bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l').textContent = ''; }; const finish = done => { row.classList.remove('nui-pod-swiping'); const gone = bg; bg = null; if (!done) { row.style.transition = 'transform 0.18s ease'; row.style.transform = ''; setTimeout(() => { row.style.transition = ''; if (gone) gone.remove(); }, 200); } else if (gone) { setTimeout(() => gone.remove(), 220); } }; row.addEventListener('pointerdown', ev => { if (ev.target.closest('.nui-pod-handle') || (ev.pointerType === 'mouse' && ev.button !== 0)) return; start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'}; mode = ''; dx = vx = 0; last = {x: ev.clientX, t: ev.timeStamp}; swallow = false; stopTimer(); if (!ev.target.closest('button')) { timer = setTimeout(() => { timer = 0; if (mode || !start) return; mode = 'long'; swallow = true; onLong(); }, 550); } }); row.addEventListener('pointermove', ev => { if (!start || ev.pointerId !== start.id) return; const mx = ev.clientX - start.x; const my = ev.clientY - start.y; if (!mode) { if (Math.abs(mx) > 8 || Math.abs(my) > 8) { stopTimer(); start.moved = true; } if (onSwipe && start.touch && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.5) { mode = 'swipe'; commitSwipe(); try { row.setPointerCapture(ev.pointerId); } catch (e) {} bg = el('div', 'nui-pod-swipebg'); bg.append(el('span', 'nui-pod-swipe-l'), el('span', 'nui-pod-swipe-r')); bg.style.top = row.offsetTop + 'px'; bg.style.height = row.offsetHeight + 'px'; row.parentElement.insertBefore(bg, row); row.classList.add('nui-pod-swiping'); } else if (Math.abs(my) > 12) { start = null; return; } } if (mode !== 'swipe') return; ev.preventDefault(); const dt = ev.timeStamp - last.t; if (dt > 0) vx = 0.7 * vx + 0.3 * ((ev.clientX - last.x) / dt); last = {x: ev.clientX, t: ev.timeStamp}; dx = mx; paint(); }); const end = ev => { if (!start || (ev && ev.pointerId !== start.id)) return; stopTimer(); const moved = start.moved; start = null; // A drag (a mouse's too) isn't a tap: no playing by accident. if (mode !== 'swipe') { if (moved && mode !== 'long') swallow = true; return; } swallow = true; const d = dx < 0 ? 'left' : 'right'; const act = actionFor(d); if (ev.type === 'pointerup' && armed() && act !== 'none') { row.style.transition = 'transform 0.16s ease-out'; row.style.transform = `translateX(${d === 'left' ? -row.offsetWidth : row.offsetWidth}px)`; finish(true); setTimeout(() => { const kept = onSwipe(act, d); // The row stays in the list: back in its place. if (kept !== false && row.isConnected) { row.style.transition = 'transform 0.18s ease'; row.style.transform = ''; setTimeout(() => { row.style.transition = ''; }, 200); } }, 160); } else { finish(false); } }; row.addEventListener('pointerup', end); row.addEventListener('pointercancel', end); row.addEventListener('pointerleave', ev => { if (mode !== 'swipe') { stopTimer(); } else if (ev.pointerType !== 'touch') end(ev); }); // The tap that ends a long press or a swipe isn't a tap on the row. row.addEventListener('click', ev => { if (!swallow) return; swallow = false; ev.stopPropagation(); ev.preventDefault(); }, true); row.addEventListener('contextmenu', ev => ev.preventDefault()); } // What swiping does, per list (this device). The defaults are an AntennaPod setup. const SWIPE_LISTS = { queue: {label: 'Warteschlange', left: 'delete', right: 'dequeue'}, inbox: {label: 'Neu', left: 'dismiss', right: 'queue'}, episodes: {label: 'Folgen', left: 'download', right: 'favorite'}, feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'}, downloads: {label: 'Downloads', hint: 'Folgen mit dem Filter „Geladen“', left: 'delete', right: 'delete'}, }; const SWIPE_ACTIONS = [ {id: 'none', label: 'Nichts', icon: ''}, {id: 'queue', label: 'Einreihen', icon: '+'}, {id: 'dequeue', label: 'Aus der Warteschlange', icon: '✕'}, {id: 'download', label: 'Herunterladen', icon: '↓', dl: true}, {id: 'delete', label: 'Download löschen', icon: '✕', dl: true}, {id: 'played', label: 'Gehört / ungehört', icon: '✓'}, {id: 'favorite', label: 'Favorit an / aus', icon: '★'}, {id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘'}, ]; function swipePrefs() { let all = {}; try { all = JSON.parse(localStorage.getItem('nui_pod_swipes') || '{}') || {}; } catch (e) {} return all; } function swipeAction(list, dir) { const l = SWIPE_LISTS[list]; if (!l) return 'none'; const own = (swipePrefs()[list] || {})[dir]; const id = own || l[dir]; const a = SWIPE_ACTIONS.find(x => x.id === id); return a && (!a.dl || DL) ? id : 'none'; } function swipeLabel(a) { return {played: 'Gehört', favorite: 'Favorit', dequeue: 'Entfernen', delete: 'Löschen', dismiss: 'Aus „Neu“'}[a.id] || a.label; } function swipeSettings() { openSheet('Wischgesten', Object.keys(SWIPE_LISTS).filter(id => id !== 'downloads' || DL).map(id => { const l = SWIPE_LISTS[id]; const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(id, d)) || SWIPE_ACTIONS[0]).label; return {icon: '⇆', label: l.label, hint: `← ${name('left')} · → ${name('right')}`, action: () => swipeListSheet(id)}; }).concat([{custom: el('div', 'nui-pod-meta nui-pod-sheetnote', 'Nur mit dem Finger. In der gesperrten Warteschlange ist Wischen aus. Nach jedem Wischen 4 Sekunden „Rückgängig“.')}])); } function swipeListSheet(list) { const l = SWIPE_LISTS[list]; const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`, SWIPE_ACTIONS.filter(a => !a.dl || DL).map(a => ({ icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id, hint: a.id === l[dir] ? 'Standard' : null, action: () => { const all = swipePrefs(); all[list] = Object.assign({}, all[list], {[dir]: a.id}); try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {} swipeListSheet(list); }, }))); const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(list, d)) || SWIPE_ACTIONS[0]).label; openSheet(`Wischen in „${l.label}“` + (l.hint ? ` (${l.hint})` : ''), [ {icon: '←', label: 'Nach links', hint: name('left'), action: () => pick('left')}, {icon: '→', label: 'Nach rechts', hint: name('right'), action: () => pick('right')}, {icon: '‹', label: 'Alle Listen', action: swipeSettings}, ]); } // A swipe's change waits 4 s for „Rückgängig“ where it can't simply be undone // (queued or dismissed: out of „Neu“ for good; out of the queue; played; a download // deleted): a row that leaves its list is gone at once, the change is sent then. // Downloading and starring happen at once; undoing them is their opposite. let pendingSwipe = null; // {id, hide, run, undo, timer} const recentlyGone = new Map(); function swipeGone(id) { if (pendingSwipe && pendingSwipe.hide && pendingSwipe.id === id) return true; const t = recentlyGone.get(id); return !!t && Date.now() - t < 4000; } function commitSwipe() { const p = pendingSwipe; if (!p) return; pendingSwipe = null; clearTimeout(p.timer); undoBar(null); if (p.hide) recentlyGone.set(p.id, Date.now()); if (p.run) p.run(); } function undoSwipe() { const p = pendingSwipe; if (!p) return; pendingSwipe = null; clearTimeout(p.timer); undoBar(null); if (p.undo) p.undo(); render(); } // The bar with „Rückgängig“ (the toast can't be tapped). function undoBar(text) { let bar = $id('nui-pod-undo'); if (!text) { if (bar) bar.classList.remove('nui-show'); return; } if (!bar) { bar = el('div'); bar.id = 'nui-pod-undo'; bar.append(el('span', 'nui-pod-undo-text')); const b = el('button', 'nui-pod-undo-btn', 'Rückgängig'); b.type = 'button'; b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); }); bar.append(b); document.body.append(bar); } const t = $id('nui-radio-toast'); if (t) t.classList.remove('nui-show'); bar.querySelector('.nui-pod-undo-text').textContent = text; bar.classList.add('nui-show'); } function offerUndo(e, text, change) { commitSwipe(); pendingSwipe = Object.assign({id: e.id}, change); pendingSwipe.timer = setTimeout(commitSwipe, 4000); undoBar(text); if (change.hide) render(); } // A swipe on an episode of `list` asks for `act`. Returns false when the row leaves the list. function swipeDone(e, list, act, filters) { const o = e.raw || {}; const has = d => DL && downloads[e.id] && downloads[e.id].state === d; if (act === 'queue') { if (inQueue(e.id)) { toast('Schon in der Warteschlange'); return true; } // Queued episodes leave „Neu“ for good (AntennaPod's rule): sent only after the 4 s. const hide = list === 'inbox'; offerUndo(e, 'Eingereiht', {hide, run: () => { N.podcastEnqueue(JSON.stringify([o]), '', currentId()); o.in_queue = true; if (!hide) render(); }}); return !hide; } if (act === 'dequeue') { if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; } const hide = list === 'queue'; offerUndo(e, 'Aus der Warteschlange genommen', {hide, run: () => { N.podcastDequeue(e.id); o.in_queue = false; }}); return !hide; } if (act === 'download') { if (!(o.audio_url || o.episode__audio_url)) { toast('Diese Folge lässt sich nicht laden'); return true; } if (downloads[e.id] && downloads[e.id].state !== 'failed') { toast(has('done') ? 'Schon heruntergeladen' : 'Wird schon geladen'); return true; } dlStart(e); offerUndo(e, 'Wird heruntergeladen', {undo: () => N.podcastDeleteDownload(e.id)}); return true; } if (act === 'delete') { if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; } const running = !has('done') && !has('failed'); // "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too. const out = !running && 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(e.id); const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out); offerUndo(e, running ? 'Download abgebrochen' : 'Download gelöscht', {hide, run: () => dlDelete(e, running, true)}); return !hide; } if (act === 'played') { const played = !(o.played || e.played); const hide = played ? list === 'inbox' || (list === 'queue' && devicePrefs().remove_played !== false) || filters.includes('unplayed') || filters.includes('in_progress') : filters.includes('played'); offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => { N.podcastMarkPlayed(e.id, played); o.played = played; if (played) o.position_seconds = 0; if (!hide) render(); }}); return !hide; } if (act === 'favorite') { toggleFavorite(e, star => { o.favorite = star; offerUndo(e, star ? '★ Favorit' : 'Kein Favorit mehr', {undo: () => toggleFavorite(e, s => { o.favorite = s; }, true)}); }, true); return true; } if (act === 'dismiss') { // „Neu“ is every unplayed episode not taken out (the page knows only the first ones). if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; } const hide = list === 'inbox'; offerUndo(e, 'Aus „Neu“ entfernt', {hide, run: () => N.podcastDismiss(JSON.stringify([e.id]))}); return !hide; } return true; } function renderQueue() { const items = queue().map(ep); if (!items.length) { empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.'); return; } const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0); if (selected) { root.append(selectionBar(items, [ {label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))}, {label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Entfernen', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, ])); } else { const head = el('div', 'nui-pod-head'); const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : ''; head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}`), listTools([ button('⇅', 'Sortieren oder mischen', sortMenu), button('☐', 'Mehrere auswählen', () => startSelecting()), button(locked ? '🔒' : '🔓', locked ? 'Warteschlange entsperren' : 'Warteschlange sperren', toggleLock, locked ? 'nui-pod-lock on' : 'nui-pod-lock'), ])); root.append(head); } const list = el('div', 'nui-pod-list nui-pod-queue'); for (const e of items) { const row = episodeRow(e, {showFeed: true, list: 'queue'}); if (!selected) { if (!locked && !keepSorted()) { const handle = el('div', 'nui-pod-handle', '≡'); handle.title = 'Ziehen zum Umsortieren'; handle.addEventListener('click', ev => ev.stopPropagation()); dragSort(handle, row, list); row.append(handle); } } list.append(row); } root.append(list); } function renderInbox() { const items = inbox().map(ep); if (!items.length) { empty(feeds().length ? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.' : 'Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } if (selected) { const chosen = ids => items.filter(e => ids.includes(e.id)); const add = (ids, where) => { N.podcastEnqueue(JSON.stringify(chosen(ids).map(e => e.raw)), where, currentId()); toast(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} eingereiht`); stopSelecting(); }; root.append(selectionBar(items, [ {label: 'Einreihen', run: ids => openSheet(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} einreihen`, [ {icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label, action: () => add(ids, '')}, {icon: '⤴', label: 'Als Nächstes', action: () => add(ids, 'after_current')}, {icon: '⇈', label: 'An den Anfang', action: () => add(ids, 'front')}, {icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')}, ])}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }}, DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))}, ])); } else { const head = el('div', 'nui-pod-head'); const total = inboxTotal(); head.append(el('div', 'nui-pod-summary', `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` + (total > items.length ? ` · die ${items.length} neuesten` : '')), listTools([button('☐', 'Mehrere auswählen', () => startSelecting())])); root.append(head); } const list = el('div', 'nui-pod-list'); for (const e of items) list.append(episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'})); root.append(list); } // The subscriptions: sorted, each with a number (new, unplayed or downloaded // episodes), both remembered on this device. const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || '')); const byTitle = (a, b) => plain(a.title).localeCompare(plain(b.title), 'de', {sensitivity: 'base'}); const FEED_SORTS = [ {id: 'latest', label: 'Neueste Folge zuerst', cmp: byDate('latest_episode_at')}, {id: 'title', label: 'Nach Titel', cmp: byTitle}, {id: 'count', label: 'Nach Zähler', hint: 'die meisten zuerst', cmp: (a, b) => (feedCount(b) || 0) - (feedCount(a) || 0) || byTitle(a, b)}, {id: 'played', label: 'Zuletzt gehört', cmp: (a, b) => byDate('last_played_at')(a, b) || byDate('latest_episode_at')(a, b)}, {id: 'added', label: 'Zuletzt abonniert', cmp: byDate('added_at')}, ]; const COUNTERS = [ {id: 'new', label: 'Neue Folgen', hint: 'wie in „Neu“', value: f => f.new_count}, {id: 'unplayed', label: 'Ungehörte Folgen', value: f => f.unplayed_count}, {id: 'downloaded', label: 'Geladene Folgen', app: true, value: f => Object.values(downloads).filter(d => d.state === 'done' && d.feedId === f.id).length}, {id: 'none', label: 'Keine Zahl', value: () => null}, ]; let feedSort = 'latest'; let feedCounter = 'new'; try { feedSort = localStorage.getItem('nui_pod_feedsort') || feedSort; feedCounter = localStorage.getItem('nui_pod_counter') || feedCounter; } catch (e) {} function counter() { return COUNTERS.find(c => c.id === feedCounter && (!c.app || DL)) || COUNTERS[0]; } function feedCount(f) { const c = counter(); return c.id === 'none' ? COUNTERS[1].value(f) : c.value(f); } function feedSortMenu() { const pick = (key, value) => { if (key === 'sort') feedSort = value; else feedCounter = value; try { localStorage.setItem(key === 'sort' ? 'nui_pod_feedsort' : 'nui_pod_counter', value); } catch (e) {} render(); }; openSheet('Abos sortieren', FEED_SORTS.map(s => ({ icon: s.id === feedSort ? '●' : '○', label: s.label, hint: s.hint || null, active: s.id === feedSort, action: () => pick('sort', s.id), })).concat([{icon: '#', label: 'Zahl am Podcast', hint: counter().label, action: () => openSheet('Zahl am Podcast', COUNTERS.filter(c => !c.app || DL).map(c => ({ icon: c.id === counter().id ? '●' : '○', label: c.label, hint: c.hint || null, active: c.id === counter().id, action: () => pick('counter', c.id), })))}])); } function feedRow(f) { const row = el('div', 'nui-pod-row'); row.append(art(f.artwork_url, null, null, f.title)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(f.title))); const meta = []; if (f.author) meta.push(plain(f.author)); if (feedSort === 'played' && f.last_played_at) meta.push('gehört ' + day(f.last_played_at)); else if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at)); main.append(el('div', 'nui-pod-meta', meta.join(' · '))); if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error)); row.append(main); if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still')); else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto')); const n = counter().value(f); if (n) { const c = el('span', 'nui-pod-count nui-pod-feedcount', String(n)); c.title = `${n} ${counter().label.replace(' Folgen', '').toLowerCase()}`; row.append(c); } row.addEventListener('click', () => openFeed(f)); return row; } function renderFeeds() { const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0]; const items = feeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); if (!items.length) { empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } const head = el('div', 'nui-pod-head'); head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`), listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)])); root.append(head); const list = el('div', 'nui-pod-list'); for (const f of items) list.append(feedRow(f)); root.append(list); } async function openFeed(f) { feedView = {feed: f, episodes: null}; selected = null; render(); scrollTop(0); try { const r = await fetch(`/podcasts/feeds/${f.id}/episodes/`); if (!r.ok) throw new Error(r.status); const data = await r.json(); if (feedView && feedView.feed.id === f.id) { feedView = {feed: Object.assign({}, f, data.feed), episodes: data.episodes}; render(); } } catch (e) { if (feedView && feedView.feed.id === f.id) { feedView.episodes = []; feedView.error = true; render(); } } } // One podcast: cover, title, author, ⓘ and ⚙, one quiet line with what is set // differently from the defaults, then its episodes. All its settings are in the ⚙ sheet. function renderFeed() { const f = feedView.feed; const live = feeds().find(x => x.id === f.id) || f; const head = el('div', 'nui-pod-feedhead'); head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; selected = null; render(); }, 'nui-pod-back-btn'), art(f.artwork_url, 'big', null, f.title)); const info = el('div', 'nui-pod-main'); info.append(el('div', 'nui-pod-title', plain(f.title))); if (f.author) info.append(el('div', 'nui-pod-meta', plain(f.author))); head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn')); if (feedHasSettings(live)) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear')); root.append(head); const line = feedHasSettings(live) ? feedSummary(f, live) : ''; if (line) { const s = el('button', 'nui-pod-feedsum', line); s.type = 'button'; s.title = 'Einstellungen für diesen Podcast'; s.addEventListener('click', () => feedSettingsSheet(f)); root.append(s); } if (live.last_error) { root.append(el('div', 'nui-pod-feederr nui-pod-feederr-box', `⚠ Der Feed ließ sich zuletzt nicht laden (${live.last_error}${live.last_error_at ? ', ' + day(live.last_error_at) : ''}). ` + 'Der Server versucht es weiter, bei wiederholten Fehlern seltener.')); } renderFeedEpisodes(f); } function feedHasSettings(live) { return !!N.podcastFeedSettings || !!N.podcastAutoQueue || !!N.podcastFeedPrefs; } function volumeLabel(v) { if (!v || v === 1) return ''; return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || ''; } // This podcast's own playback (speed, intro, outro, volume): the server's where it keeps them, else this device's. function feedOwn(f) { const live = feeds().find(x => x.id === f.id) || f; return 'skip_intro' in live ? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1} : (devicePrefs().feeds || {})[String(f.id)] || {}; } const NEW_WHERE = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'}; function filterWords(live) { return [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`, live.min_duration && `ab ${live.min_duration} min`].filter(Boolean).join(', '); } // "1,3× · Intro 20 s · ohne „Trailer“ · neue Folgen → Warteschlange": only what differs from the defaults. function feedSummary(f, live) { const own = N.podcastFeedPrefs ? feedOwn(f) : {}; const parts = []; if (own.speed) parts.push(speedLabel(own.speed)); if (own.intro) parts.push(`Intro ${own.intro} s`); if (own.outro) parts.push(`Outro ${own.outro} s`); if (volumeLabel(own.volume)) parts.push('Lautstärke ' + volumeLabel(own.volume)); if ('skip_silence' in live && live.skip_silence != null) parts.push(live.skip_silence ? 'Stille überspringen' : 'Stille nie überspringen'); const words = filterWords(live); if (words) parts.push(words); if (live.new_episodes === 'queue' || (!('new_episodes' in live) && live.auto_queue)) parts.push('neue Folgen → Warteschlange'); else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends'); if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download'); if (live.notify) parts.push('Benachrichtigen'); return parts.join(' · '); } // All of a podcast's settings in one sheet; after a change it opens again with the new values. function feedSettingsSheet(f) { const live = feeds().find(x => x.id === f.id) || f; const title = plain(live.title || f.title); const again = () => setTimeout(() => { if (feedView && feedView.feed.id === f.id && !preview) feedSettingsSheet(f); }, 180); const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); setTimeout(render, 50); again(); }; const choose = (head, choices) => () => openSheet(head, choices); const items = []; const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase(); if ('new_episodes' in live && N.podcastFeedSettings) { const ne = live.new_episodes || 'inbox'; items.push({icon: '◇', label: 'Neue Folgen', hint: NEW_WHERE[ne] || NEW_WHERE.inbox, active: ne !== 'inbox', action: choose(`Neue Folgen von „${title}“ …`, [ {icon: ne === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', active: ne === 'inbox', action: () => save({new_episodes: 'inbox'})}, {icon: ne === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, active: ne === 'queue', action: () => save({new_episodes: 'queue'})}, {icon: ne === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', active: ne === 'ignore', action: () => save({new_episodes: 'ignore'})}, ])}); // AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves. if ('auto_download' in live && N.podcastDownloads) { items.push({icon: '↓', label: 'Automatisch herunterladen', hint: live.auto_download ? 'an: neue Folgen im WLAN, im Download-Limit' : 'aus', active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})}); } // AntennaPod's episode notifications: the phone tells when a new episode is in „Neu“. if ('notify' in live) { items.push({icon: '◔', label: 'Benachrichtigen', hint: live.notify ? 'an: eine Mitteilung bei jeder neuen Folge' : 'aus', active: !!live.notify, action: () => save({notify: !live.notify})}); } const words = filterWords(live); const ask = async (key, head, now) => { const v = typeof customPrompt === 'function' ? await customPrompt(head, now || '') : null; if (v != null) save({[key]: v.trim()}); else again(); }; items.push({icon: '⊻', label: 'Filter für neue Folgen', hint: words || 'keiner', active: !!words, action: choose('Welche neuen Folgen zählen?', [ {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle', action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)}, {icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine', action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)}, {icon: '⏱︎', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine', action: choose('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({ icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge', active: (live.min_duration || 0) === m, action: () => save({min_duration: m}), })))}, ])}); } else if (N.podcastAutoQueue) { items.push({icon: live.auto_queue ? '☑' : '☐', label: 'Neue Folgen automatisch einreihen', hint: pos, active: !!live.auto_queue, action: () => { N.podcastAutoQueue(f.id, !live.auto_queue); again(); }}); } if (N.podcastFeedPrefs) { const own = feedOwn(f); const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); again(); }; const seconds = (key, head) => choose(head, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({ icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', active: (own[key] || 0) === v, action: () => set(key, v), }))); items.push( {icon: '»', label: 'Tempo', hint: own.speed ? speedLabel(own.speed) : 'wie alle Podcasts', active: !!own.speed, action: choose(`Geschwindigkeit für „${title}“`, speedChoices(own.speed).map(v => ({ icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v), active: Math.abs((own.speed || 0) - v) < 0.01, action: () => set('speed', v)})) .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]) .concat(presetsEditItem()))}, {icon: '⇥', label: 'Intro überspringen', hint: own.intro ? `${own.intro} Sekunden` : 'aus', active: !!own.intro, action: seconds('intro', 'Am Anfang jeder Folge überspringen')}, {icon: '⇤', label: 'Outro überspringen', hint: own.outro ? `${own.outro} Sekunden` : 'aus', active: !!own.outro, action: seconds('outro', 'Am Ende jeder Folge überspringen')}, {icon: '◖', label: 'Lautstärke', hint: volumeLabel(own.volume) || 'normal', active: !!volumeLabel(own.volume), action: choose(`Lautstärke für „${title}“`, [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']] .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, active: (own.volume || 1) === v, action: () => set('volume', v)})))}, ); } // Skip silence for this podcast (server), where this player can skip silence at all. if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) { const mine = live.skip_silence; const device = !!devicePrefs().skip_silence; items.push({icon: '∿', label: 'Stille', hint: mine === true ? 'überspringen' : mine === false ? 'nie überspringen' : 'wie eingestellt', active: mine != null, action: choose(`Stille in „${title}“ …`, [ {icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)', active: mine == null, action: () => save({skip_silence: null})}, {icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', active: mine === true, action: () => save({skip_silence: true})}, {icon: mine === false ? '●' : '○', label: 'Nie überspringen', active: mine === false, action: () => save({skip_silence: false})}, ])}); } // The list's order (its filters are the chips above the episodes). const prefs = feedListPrefs(f.id); const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new'; items.push({icon: '⇅', label: 'Folgen sortieren', hint: sortLabel(sort), active: sort !== 'new', action: () => sortMenuFor(sort, false, id => { saveFeedListPrefs(f.id, {filters: (prefs.filters || []).slice(), sort: id}); render(); again(); })}); items.push({custom: el('div', 'nui-pod-sheetsep')}); items.push({icon: '✕', label: 'Abo beenden', hint: 'fragt noch einmal nach', action: () => confirmUnsubscribe(f)}); openSheet(`Einstellungen für „${title}“`, items); } // Unsubscribing (server: POST /podcasts/feeds//remove/). Asked once more; then the // server forgets the podcast with its episodes, and the app deletes their downloads // (not those of favourites). function confirmUnsubscribe(f) { if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; } openSheet(`„${plain(f.title)}“ nicht mehr abonnieren?`, [ {custom: el('div', 'nui-pod-meta nui-pod-sheetnote', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt ' + 'und Favoriten, auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : ''))}, {icon: '✕', label: 'Abo beenden', danger: true, action: () => unsubscribe(f)}, {icon: '‹', label: 'Abbrechen', action: () => feedSettingsSheet(f)}, ]); } function renderFeedEpisodes(f) { if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; } if (feedView.error) { empty('Die Folgen lassen sich gerade nicht laden. Das braucht eine Verbindung.'); return; } for (const o of feedView.episodes) { // Feed episodes don't name their podcast; queue entries need it. o.feed__id = f.id; o.feed__title = f.title; o.feed__artwork_url = f.artwork_url; } const prefs = feedListPrefs(f.id); const filters = (prefs.filters || []).filter(id => FILTERS.some(x => x.id === id && !x.all && (!x.app || DL))); const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new'; const all = feedView.episodes; const items = sortEpisodes(all.filter(o => matchesAll(o, filters)), sort).map(ep); if (selected) { root.append(episodeSelection(items)); } else { const save = changes => { saveFeedListPrefs(f.id, Object.assign({filters, sort}, changes)); render(); }; root.append(filterRow(filters, next => save({filters: next}), false)); const head = el('div', 'nui-pod-head'); head.append(el('div', 'nui-pod-summary', (items.length === all.length ? nEpisodes(all.length) : `${items.length} von ${nEpisodes(all.length)}`) + ' · ' + sortLabel(sort).toLowerCase()), listTools([ button('⇅', 'Sortieren', () => sortMenuFor(sort, false, id => save({sort: id}))), items.length > 0 && button('☐', 'Mehrere auswählen', () => startSelecting()), ])); root.append(head); } if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; } const list = el('div', 'nui-pod-list'); for (const e of items) { const o = e.raw; const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters}); if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); list.append(row); } root.append(list); } // A podcast's list: its filters and order, per podcast. On the server (episode_sort, // episode_filter: the same on every device) where it knows them; else on this device. const serverLists = live => !!live && 'episode_sort' in live && !!N.podcastFeedSettings; function feedListPrefs(id) { const live = feeds().find(x => x.id === id); if (serverLists(live)) { return {sort: live.episode_sort || 'new', filters: Array.isArray(live.episode_filter) ? live.episode_filter : []}; } try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; } } function saveFeedListPrefs(id, prefs) { const live = feeds().find(x => x.id === id); if (serverLists(live)) { const sort = prefs.sort === 'new' ? '' : prefs.sort; // Shows at once; the host sends it on. live.episode_sort = sort; live.episode_filter = prefs.filters.slice(); N.podcastFeedSettings(id, JSON.stringify({episode_sort: sort, episode_filter: prefs.filters})); return; } try { const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}'); if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs; localStorage.setItem('nui_pod_feedlists', JSON.stringify(all)); } catch (e) {} } // Once the server keeps the lists: what this device remembered goes there (where the // server has nothing set for that podcast yet), then the device forgets it. function moveFeedListsToServer() { if (!N.podcastFeedSettings) return; let all = null; try { all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || 'null'); } catch (e) { all = null; } if (!all) return; const list = feeds(); if (!list.length || !list.some(f => 'episode_sort' in f)) return; try { localStorage.removeItem('nui_pod_feedlists'); } catch (e) { return; } for (const id of Object.keys(all)) { const live = list.find(f => String(f.id) === id); const p = all[id] || {}; if (!serverLists(live) || live.episode_sort || (live.episode_filter || []).length) continue; const filters = (Array.isArray(p.filters) ? p.filters : []).filter(x => FILTERS.some(y => y.id === x && !y.all)); const sort = EP_SORTS.some(s => s.id === p.sort && !s.all) ? p.sort : 'new'; if (filters.length || sort !== 'new') saveFeedListPrefs(live.id, {filters, sort}); } } // ---- a podcast's info page: description, website, feed address, numbers ---------------- // In the shownotes' sheet (closeNotes closes it, also with the back button). function openFeedInfo(f) { const live = Object.assign({}, f, feedView && feedView.feed.id === f.id ? feedView.feed : {}, feeds().find(x => x.id === f.id) || {}); closeNotes(); notesSheet = el('div'); notesSheet.id = 'nui-pod-player'; notesSheet.className = 'nui-pod-notes-sheet nui-pod-info'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeNotes)); const head = el('div', 'nui-pod-feedhead'); const title = el('div', 'nui-pod-main'); title.append(el('div', 'nui-pod-title', plain(live.title))); if (live.author) title.append(el('div', 'nui-pod-meta', plain(live.author))); head.append(art(live.artwork_url, 'big', null, live.title), title); panel.append(top, head); // Feed descriptions may carry HTML: only its text. const text = live.description ? (new DOMParser().parseFromString(live.description, 'text/html').body.textContent || '').trim() : ''; if (text) panel.append(el('div', 'nui-pod-info-desc', text)); else if (feedView && feedView.feed.id === f.id && feedView.episodes == null) { panel.append(el('div', 'nui-pod-meta', 'Beschreibung wird geladen …')); } const facts = el('dl', 'nui-pod-facts'); const fact = (label, ...value) => { if (!value.length || value[0] == null || value[0] === '') return; const dd = el('dd'); dd.append(...value.map(v => (typeof v === 'string' ? document.createTextNode(v) : v))); facts.append(el('dt', null, label), dd); }; const when = iso => (iso ? day(iso) + ', ' + new Date(iso).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : null); if (live.link && /^https?:\/\//i.test(live.link)) { const a = el('a', null, live.link.replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, '')); a.href = live.link; a.target = '_blank'; a.rel = 'noopener'; fact('Website', a); } if (live.rss_url) { const row = el('div', 'nui-pod-info-actions'); const action = (label, onClick) => { const b = el('button', 'btn', label); b.type = 'button'; b.addEventListener('click', onClick); row.append(b); }; action('Kopieren', () => copyText(live.rss_url)); if (navigator.share) action('Teilen', () => navigator.share({title: plain(live.title), url: live.rss_url}).catch(() => {})); fact('Feed', el('span', 'nui-pod-info-url', live.rss_url), row); } const count = live.episode_count != null ? live.episode_count : feedView && feedView.feed.id === f.id && feedView.episodes ? feedView.episodes.length : null; if (count != null) { fact('Folgen', [String(count), live.unplayed_count != null && `${live.unplayed_count} ungehört`, live.new_count && `${live.new_count} neu`].filter(Boolean).join(' · ')); } if (live.latest_episode_at) fact('Neueste Folge', day(live.latest_episode_at)); if (live.last_played_at) fact('Zuletzt gehört', day(live.last_played_at)); if (live.added_at) fact('Abonniert', new Date(live.added_at).toLocaleDateString('de-DE', {day: 'numeric', month: 'long', year: 'numeric'})); fact('Abgerufen', when(live.last_refreshed_at) || 'noch nie'); if (live.last_error) { fact('Fehler', el('span', 'nui-pod-feederr', `⚠ ${live.last_error}${live.last_error_at ? ' (' + day(live.last_error_at) + ')' : ''}` + (live.error_count > 1 ? ` · ${live.error_count}× nacheinander` : ''))); } panel.append(facts); notesSheet.append(panel); notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); document.body.append(notesSheet); } async function copyText(text) { try { await navigator.clipboard.writeText(text); toast('Kopiert'); return; } catch (e) {} const t = el('textarea'); t.value = text; t.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.append(t); t.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e) {} t.remove(); toast(ok ? 'Kopiert' : 'Kopieren ging nicht'); } async function unsubscribe(f) { if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; } toast('Abo wird beendet …'); try { const r = await fetch(`/podcasts/feeds/${f.id}/remove/`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}', }); // 404: gone already (removed elsewhere) — the same result. if (!r.ok && r.status !== 404) throw new Error(r.status); } catch (e) { toast(navigator.onLine ? 'Abo beenden hat nicht geklappt.' : 'Abo beenden braucht eine Verbindung.'); return; } // The episodes the page knows of it: downloads from before the app kept their podcast match by id. const ids = ((feedView && feedView.feed.id === f.id && feedView.episodes) || []).map(o => o.id) .concat(queue().filter(q => q.episode__feed__id === f.id).map(q => q.episode__id)) .concat(inbox().filter(o => o.feed__id === f.id).map(o => o.id)); state.feeds = feeds().filter(x => x.id !== f.id); if (feedView && feedView.feed.id === f.id) feedView = null; refreshing = true; render(); toast(`„${plain(f.title)}“ abbestellt`); // The app deletes the downloads and fetches the state again. if (N.podcastFeedRemoved) N.podcastFeedRemoved(f.id, JSON.stringify(ids)); else N.podcastRefresh(false); } // The downloads list: an episode list with just the filter „Geladen“ (its button plays, its swipes delete). const downloadsOnly = filters => DL && filters.length === 1 && filters[0] === 'downloaded'; // ---- all episodes, filtered (server: GET /podcasts/episodes/) ---------------------- // Filters combine (all have to match); "Geladen" asks for the episodes downloaded here. let epFilters = []; let epSort = 'new'; try { const kept = localStorage.getItem('nui_pod_filters'); if (kept != null) epFilters = JSON.parse(kept); else { // Before filters combined: one of them. const old = localStorage.getItem('nui_pod_filter'); epFilters = old == null ? ['unplayed'] : old === 'all' ? [] : [old]; if (old === 'history') epSort = 'played'; } epSort = localStorage.getItem('nui_pod_epsort') || epSort; } catch (e) {} if (!Array.isArray(epFilters)) epFilters = []; epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || !!N.podcastDownloads))); if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new'; let epList = null; // {key, items, total, loading, error} const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort; function setEpisodeList(filters, sort) { // The history goes by the last listening; leaving it, by date again. if (sort == null) { sort = epSort; if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played'; if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new'; } epFilters = filters; epSort = sort; try { localStorage.setItem('nui_pod_filters', JSON.stringify(epFilters)); localStorage.setItem('nui_pod_epsort', epSort); } catch (e) {} loadEpisodes(false); } async function loadEpisodes(append, quiet) { const key = epKey(); const offset = append && epList && epList.key === key ? epList.items.length : 0; if (!append) epList = {key, items: [], total: 0, loading: true}; else epList.loading = true; if (!quiet) render(); const params = new URLSearchParams({ filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: epSort, limit: '100', offset: String(offset), }); if (epFilters.includes('downloaded')) { const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done'); if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; } params.set('ids', ids.join(',')); } try { const r = await fetch(`/podcasts/episodes/?${params}`); if (r.status === 400) throw new Error('old'); if (!r.ok) throw new Error(r.status); const d = await r.json(); if (!epList || epList.key !== key) return; epList = {key, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { if (!epList || epList.key !== key) return; epList.loading = false; epList.error = e.message === 'old' ? 'Der Server kennt diese Filter noch nicht.' : 'Die Folgenliste braucht eine Verbindung.'; } render(); } function renderEpisodes() { if (!selected) root.append(filterRow(epFilters, next => setEpisodeList(next), true)); if (!epList || epList.key !== epKey()) { loadEpisodes(false, true); empty('Wird geladen …'); return; } if (epList.error && !epList.items.length) { empty(epList.error); return; } // What changed here since (marked, starred, queued) shows at once. const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep); const total = Math.max(items.length, epList.total - (epList.items.length - items.length)); if (!items.length) { empty(epList.loading ? 'Wird geladen …' : noneText(epFilters)); return; } if (selected) { root.append(episodeSelection(items)); } else { const head = el('div', 'nui-pod-head'); head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`), listTools([ button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))), button('☐', 'Mehrere auswählen', () => startSelecting()), ])); root.append(head); } const list = el('div', 'nui-pod-list'); for (const e of items) { const o = e.raw; const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes', filters: epFilters}); if (epSort === 'played' && o.last_played_at) { const m = row.querySelector('.nui-pod-meta'); const mark = m.querySelector('.nui-pod-dlmark'); m.textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || ''); if (mark) m.prepend(mark); } if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); list.append(row); } root.append(list); if (epList.items.length < epList.total) { const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); more.type = 'button'; more.disabled = !!epList.loading; more.addEventListener('click', () => loadEpisodes(true)); root.append(more); } } // ---- favourites ------------------------------------------------------------------------- function favoriteItem(e) { const o = e.raw || {}; const known = typeof o.favorite === 'boolean'; return {icon: o.favorite ? '★' : '☆', label: known ? (o.favorite ? 'Kein Favorit mehr' : 'Als Favorit markieren') : 'Favorit an/aus', action: () => toggleFavorite(e, star => { if (e.raw) e.raw.favorite = star; })}; } async function toggleFavorite(e, done, quiet) { const o = e.raw || {}; const body = typeof o.favorite === 'boolean' ? {favorite: !o.favorite} : {}; try { const r = await fetch(`/podcasts/episodes/${e.id}/favorite/`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); if (!r.ok) throw new Error(r.status); const d = await r.json(); if (!quiet) toast(d.favorite ? '★ Als Favorit markiert' : 'Kein Favorit mehr'); // Favourites keep their download (Downloads.kt). if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, !!d.favorite); const cached = storedDetails(e.id); if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite})); if (done) done(d.favorite); if (epList) { const item = epList.items.find(x => x.id === e.id); if (item) item.favorite = d.favorite; } // The ★ in the lists (Folgen, one podcast, search) and their filters. render(); } catch (err) { toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); } } // ---- listening statistics (server: GET /podcasts/stats/) ----------------------------- let statsSheet = null; function closeStats() { if (!statsSheet) return false; statsSheet.remove(); statsSheet = null; return true; } function hours(seconds) { const h = Math.floor(seconds / 3600); const m = Math.round((seconds % 3600) / 60); return h ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`; } function openStats() { closeStats(); statsSheet = el('div'); statsSheet.id = 'nui-pod-player'; statsSheet.className = 'nui-pod-notes-sheet'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeStats)); const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-stats'); panel.append(top, el('div', 'nui-pod-player-title', 'Hörstatistik'), tabs, body); statsSheet.append(panel); statsSheet.addEventListener('click', ev => { if (ev.target === statsSheet) closeStats(); }); document.body.append(statsSheet); const ranges = [{days: 30, label: '30 Tage'}, {days: 365, label: 'Jahr'}, {days: 0, label: 'Gesamt'}]; const show = async range => { tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.days === String(range.days))); body.textContent = ''; body.append(el('div', 'nui-pod-meta', 'Wird geladen …')); let d = null; try { const r = await fetch('/podcasts/stats/' + (range.days ? `?days=${range.days}` : '')); if (!r.ok) throw new Error(r.status); d = await r.json(); } catch (e) { body.textContent = ''; body.append(el('div', 'nui-pod-meta', 'Die Statistik braucht eine Verbindung.')); return; } body.textContent = ''; const big = el('div', 'nui-pod-stat-total'); big.append(el('strong', null, hours(d.total_seconds || 0)), el('span', null, `${d.episodes_played || 0} Folgen gehört · ${d.episodes_started || 0} angefangen`)); body.append(big); const feedsList = d.feeds || []; if (!feedsList.length) { body.append(el('div', 'nui-pod-meta', 'In diesem Zeitraum nichts gehört.')); return; } const max = Math.max(...feedsList.map(f => f.seconds || 0), 1); for (const f of feedsList) { const row = el('div', 'nui-pod-stat-row'); row.append(art(f.artwork_url, null, null, f.title)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(f.title)), el('div', 'nui-pod-meta', `${hours(f.seconds || 0)} · ${f.episodes_played || 0} Folgen`)); const bar = el('div', 'nui-pod-progress nui-pod-stat-bar'); const fill = el('span'); fill.style.width = Math.max(2, Math.round((f.seconds || 0) / max * 100)) + '%'; bar.append(fill); main.append(bar); row.append(main); body.append(row); } }; for (const r of ranges) { const b = el('button', 'nui-pod-subtab', r.label); b.type = 'button'; b.dataset.days = String(r.days); b.addEventListener('click', () => show(r)); tabs.append(b); } show(ranges[0]); } // ---- OPML: take the subscriptions to another app, or bring them in ------------------- async function exportOpml() { try { const r = await fetch('/podcasts/feeds/export.opml'); if (!r.ok) throw new Error(r.status); const blob = new Blob([await r.text()], {type: 'text/x-opml'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'diora-podcasts.opml'; a.click(); // the app offers to share or save it (native.js) setTimeout(() => URL.revokeObjectURL(a.href), 60000); } catch (e) { toast(navigator.onLine ? 'Export hat nicht geklappt' : 'Der Export braucht eine Verbindung'); } } function importOpml() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.opml,.xml,text/x-opml,text/xml,application/xml'; input.addEventListener('change', async () => { const file = input.files && input.files[0]; if (!file) return; const form = new FormData(); form.append('file', file); toast('Abos werden importiert …'); try { const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form}); const d = await r.json(); if (!r.ok || !d.ok) throw new Error(d.error || r.status); toast(`${d.added} neue Abos${d.skipped ? `, ${d.skipped} schon vorhanden` : ''}`); refresh(true); } catch (e) { toast('Diese Datei ließ sich nicht importieren'); } }); input.click(); } // ---- search: own podcasts, their episodes (also in the shownotes), the directory ---- // searchResults: {q, feeds, eps: {items, total, loading, local}, dir: {items, loading, error}}. // Without a connection the episodes come from what this device knows (queue, // "Neu", lists seen), the podcasts from the kept state. let searchDraft = null; function fold(s) { return plain(s || '').toLocaleLowerCase('de').normalize('NFD').replace(/[̀-ͯ]/g, ''); } // All words of the query, anywhere in the text. function matcher(q) { const words = fold(q).split(/\s+/).filter(Boolean); return text => words.every(w => text.includes(w)); } function knownEpisodes() { const seen = new Map(); const add = o => { if (o && !seen.has(shape(o).id)) seen.set(shape(o).id, o); }; queue().forEach(add); inbox().forEach(add); if (epList) epList.items.forEach(add); if (feedView && feedView.episodes) feedView.episodes.forEach(add); return [...seen.values()]; } function searchText(o) { const e = shape(o); const notes = o.description ? o.description.replace(/<[^>]*>/g, ' ') : ''; return fold(e.title + ' ' + notes); } function searchNote(text) { root.append(el('div', 'nui-pod-sec-note', text)); } function renderSearch() { const form = el('form', 'nui-pod-search'); const input = el('input', 'search-input'); input.type = 'search'; input.id = 'nui-pod-q'; input.placeholder = 'Abos, Folgen, Verzeichnis – oder Feed-Adresse …'; input.setAttribute('enterkeyhint', 'search'); input.value = searchDraft != null ? searchDraft : searchResults ? searchResults.q : ''; input.addEventListener('input', () => { searchDraft = input.value; }); const go = el('button', 'btn', 'Suchen'); go.type = 'submit'; form.append(input, go); form.addEventListener('submit', ev => { ev.preventDefault(); search(input.value.trim()); }); root.append(form); const s = searchResults; if (!s) { empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' + 'Eine Feed-Adresse (https://…) abonnierst du direkt.'); return; } if (s.feeds.length) { root.append(el('div', 'nui-pod-sec', `Deine Podcasts (${s.feeds.length})`)); const list = el('div', 'nui-pod-list'); for (const f of s.feeds) list.append(feedRow(f)); root.append(list); } const eps = s.eps; root.append(el('div', 'nui-pod-sec', 'Folgen' + (eps.items.length ? ` (${eps.total})` : ''))); if (eps.local) searchNote('Ohne Verbindung: nur Folgen, die dieses Gerät kennt.'); if (!eps.items.length) { searchNote(eps.loading ? 'Wird gesucht …' : 'Keine Folgen gefunden.'); } else { const list = el('div', 'nui-pod-list'); for (const o of eps.items) { const e = ep(o); const row = episodeRow(e, {showFeed: true, showDate: true, noSelect: true}); if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); list.append(row); } root.append(list); if (!eps.local && eps.items.length < eps.total) { const more = el('button', 'btn nui-pod-more', eps.loading ? 'Wird geladen …' : 'Weitere Folgen'); more.type = 'button'; more.disabled = !!eps.loading; more.addEventListener('click', () => searchEpisodes(s, true)); root.append(more); } } const dir = s.dir; root.append(el('div', 'nui-pod-sec', 'Im Podcast-Verzeichnis')); if (dir.loading) { searchNote('Wird gesucht …'); return; } if (dir.error) { searchNote(dir.error); return; } if (!dir.items.length) { searchNote('Nichts gefunden.'); return; } const subscribed = new Set(feeds().map(f => f.rss_url)); const list = el('div', 'nui-pod-list'); for (const r of dir.items) { const row = el('div', 'nui-pod-row'); row.append(art(r.artwork_url, null, null, r.title)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', r.title), el('div', 'nui-pod-meta', [r.author, r.genre].filter(Boolean).join(' · '))); row.append(main); if (!r.rss_url) row.append(el('span', 'nui-pod-meta', 'kein Feed')); else if (subscribed.has(r.rss_url)) row.append(el('span', 'nui-pod-badge', 'abonniert')); else row.append(button('+', 'Abonnieren', () => subscribe(r.rss_url, r.title))); list.append(row); } root.append(list); } function search(q) { if (!q) return; if (/^https?:\/\//i.test(q)) { if (N.podcastPreview) openPreview(q); else subscribe(q, q); return; } searchDraft = null; const test = matcher(q); const s = searchResults = { q, feeds: feeds().filter(f => test(fold(f.title) + ' ' + fold(f.author))), eps: {items: [], total: 0, loading: true}, dir: {items: [], loading: true}, }; render(); searchEpisodes(s, false); searchDirectory(s); } async function searchEpisodes(s, more) { const offset = more ? s.eps.items.length : 0; s.eps.loading = true; if (more) render(); try { const r = await fetch(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}¬es=1&limit=30&offset=${offset}`); if (!r.ok) throw new Error(r.status); const d = await r.json(); s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { if (more) { s.eps.loading = false; toast('Weitere Folgen brauchen eine Verbindung.'); } else { const test = matcher(s.q); const items = knownEpisodes().filter(o => test(searchText(o))); s.eps = {items, total: items.length, local: true}; } } if (searchResults === s) render(); } async function searchDirectory(s) { try { const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(s.q)); const data = await r.json(); s.dir = {items: data.results || [], error: data.error ? 'Das Verzeichnis ist gerade nicht erreichbar.' : null}; } catch (e) { s.dir = {items: [], error: 'Das Verzeichnis braucht eine Verbindung.'}; } if (searchResults === s) render(); } async function subscribe(rssUrl, title) { toast('Wird abonniert …'); try { const r = await fetch('/podcasts/feeds/add/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({rss_url: rssUrl, title}), }); const data = await r.json(); if (!r.ok || !data.ok) throw new Error(data.error || r.status); toast(`„${data.title || title}“ abonniert`); refresh(false); return true; } catch (e) { toast(navigator.onLine ? 'Dieser Feed lässt sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.'); return false; } } // A feed from another app (shared link, pcast:// …) or a pasted address: // the app fetches it (N.podcastPreview, FeedPreview.kt) and the page shows // title, cover, description and the newest episodes before subscribing. function openPreview(url) { if (!N.podcastPreview) { subscribe(url, url); return; } closePlayer(); feedView = null; selected = null; preview = {id: ++previewSeq, url}; if (typeof showTab === 'function') showTab('podcasts'); render(); scrollTop(0); N.podcastPreview(preview.id, url); } function closePreview() { if (!preview) return false; preview = null; render(); return true; } function renderPreview() { const p = preview; const d = p.data || {}; const head = el('div', 'nui-pod-feedhead'); head.append(button('‹', 'Zurück', closePreview), art(d.artwork, 'big', null, d.title)); const info = el('div', 'nui-pod-main'); info.append(el('div', 'nui-pod-title', plain(d.title) || 'Podcast'), el('div', 'nui-pod-meta', plain(d.author) || p.url)); head.append(info); root.append(head); if (p.choices) { root.append(el('div', 'nui-pod-empty', 'Diese Seite verlinkt mehrere Feeds. Welcher ist es?')); const list = el('div', 'nui-pod-list'); for (const c of p.choices) { const row = el('div', 'nui-pod-row'); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(c.title) || c.url), el('div', 'nui-pod-meta', c.url)); row.append(main); row.addEventListener('click', () => openPreview(c.url)); list.append(row); } root.append(list); return; } if (p.error) { // The server may still manage (it fetches the feed itself). const box = el('div', 'nui-pod-offer', p.error); const actions = el('div', 'nui-pod-offer-row'); const anyway = el('button', 'btn', 'Trotzdem abonnieren'); anyway.type = 'button'; anyway.addEventListener('click', () => subscribeFromPreview(p.url, p.url, anyway)); actions.append(anyway); box.append(actions); root.append(box); return; } if (!p.data) { empty('Feed wird geladen …'); return; } const known = feeds().find(f => f.rss_url === d.url || f.rss_url === p.url); const actions = el('div', 'nui-pod-offer-row'); const go = el('button', 'btn', known ? 'Abonniert – öffnen' : 'Abonnieren'); go.type = 'button'; go.addEventListener('click', () => { if (known) { preview = null; openFeed(known); return; } subscribeFromPreview(d.url, d.title, go); }); actions.append(go); root.append(actions); if (d.description) { const desc = el('div', 'nui-pod-offer', plain(d.description)); desc.style.whiteSpace = 'pre-line'; root.append(desc); } const eps = d.episodes || []; if (eps.length) { const list = el('div', 'nui-pod-list'); for (const e of eps) { const row = el('div', 'nui-pod-row'); row.append(art(e.artwork, null, d.artwork, d.title)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(e.title)), el('div', 'nui-pod-meta', [e.duration && minutes(e.duration), day(e.date)].filter(Boolean).join(' · '))); row.append(main); list.append(row); } root.append(list); } } async function subscribeFromPreview(url, title, btn) { btn.disabled = true; const ok = await subscribe(url, title); btn.disabled = false; if (!ok) return; preview = null; view = 'feeds'; try { localStorage.setItem('nui_pod_view', view); } catch (e) {} render(); scrollTop(0); } // ---- drag to reorder (touch) ----------------------------------------------------------- function dragSort(handle, row, list) { handle.addEventListener('pointerdown', ev => { ev.preventDefault(); ev.stopPropagation(); handle.setPointerCapture(ev.pointerId); const rows = [...list.children]; const from = rows.indexOf(row); const rects = rows.map(r => r.getBoundingClientRect()); const startY = ev.clientY; let to = from; row.classList.add('nui-pod-dragging'); const move = e => { const dy = e.clientY - startY; row.style.transform = `translateY(${dy}px)`; const mid = rects[from].top + rects[from].height / 2 + dy; to = rects.findIndex(r => mid < r.top + r.height / 2); if (to < 0) to = rects.length - 1; rows.forEach((r, i) => { if (r === row) return; let shift = 0; if (from < to && i > from && i <= to) shift = -rects[from].height; if (from > to && i >= to && i < from) shift = rects[from].height; r.style.transform = shift ? `translateY(${shift}px)` : ''; }); }; const end = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end); rows.forEach(r => { r.style.transform = ''; }); row.classList.remove('nui-pod-dragging'); if (to !== from) { // Show the new order at once; the app's state follows right after. list.insertBefore(row, to > from ? rows[to].nextSibling : rows[to]); N.podcastMove(parseInt(row.dataset.id, 10), to); } }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end); }); } // ---- mini player and full player ----------------------------------------------------------- let mini = null; function buildMini() { const dock = $id('nui-dock'); if (!dock || $id('nui-pod-mini')) return; mini = el('div'); mini.id = 'nui-pod-mini'; mini.hidden = true; mini.innerHTML = '
'; const text = el('button', 'nui-mini-text'); text.type = 'button'; text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track')); text.addEventListener('click', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer())); const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices'); const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here'); devices.hidden = here.hidden = true; mini.append(art('', 'mini'), text, button('↺', '15 s zurück', () => (miniKind === 'remote' ? remoteCmd('skip', -(devicePrefs().skip_back || 15)) : N.podcastSkip(false)), 'nui-pod-skip'), button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'), devices, here, button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle() : miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle')); dock.prepend(mini); // One player for podcasts and radio: the radio's own bar steps aside. document.documentElement.classList.add('nui-one-player'); // A "Podcasts" button in the bottom bar, between books and radio. const tabbar = $id('nui-tabbar'); if (tabbar && !tabbar.querySelector('[data-tab="podcasts"]')) { const b = el('button'); b.type = 'button'; b.dataset.tab = 'podcasts'; b.append(el('span', 'nui-tab-icon', '◎'), document.createTextNode('Podcasts')); b.addEventListener('click', () => { showTab('podcasts'); scrollTop(0); }); tabbar.insertBefore(b, tabbar.querySelector('[data-tab="radio"]')); } } // What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote' // (another device is the active one) or ''. let miniKind = ''; function radioNow() { return typeof window.__dioraRadioNow === 'function' ? window.__dioraRadioNow() : null; } function setArt(box, src) { const img = box.querySelector('img'); src = (src || '').replace(/^http:/, 'https:'); if (!img && src) { const i = el('img'); i.alt = ''; i.src = src; i.addEventListener('error', () => i.remove()); box.append(i); } else if (img && img.getAttribute('src') !== src) { if (src) img.src = src; else img.remove(); } } function renderMini() { const r = remote(); const isPod = player.kind === 'podcast'; document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r); if (!mini) return; const radio = isPod || r ? null : radioNow(); miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : ''; mini.hidden = !miniKind; mini.classList.toggle('nui-mini-radio', miniKind === 'radio' || (!!r && r.kind === 'radio')); mini.classList.toggle('nui-mini-remote', !!r); document.documentElement.classList.toggle('nui-pod-mini-open', !!miniKind); mini.querySelector('.nui-pod-skip').hidden = !(isPod || r) || (!!r && r.kind !== 'podcast'); mini.querySelector('.nui-mini-save').hidden = miniKind !== 'radio' || radio.saved; mini.querySelector('.nui-mini-devices').hidden = !r || !N.playerTransfer; mini.querySelector('.nui-mini-here').hidden = !r || !N.playerTakeOver; if (r) { const onRadio = r.kind === 'radio'; mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || ''; mini.querySelector('.nui-mini-track').textContent = remoteLine(r); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶'; t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r); setArt(mini.querySelector('.nui-pod-art'), onRadio ? r.station && r.station.logo : r.artwork); mini.querySelector('.nui-pod-mini-bar span').style.width = onRadio ? (r.playing ? '100%' : '0') : r.dur ? Math.min(100, r.pos / r.dur * 100) + '%' : '0'; return; } if (miniKind === 'radio') { mini.querySelector('.nui-mini-station').textContent = radio.name; mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …' : radio.playing ? (radio.track || '● Live') : 'Angehalten'; const t = mini.querySelector('.nui-mini-toggle'); t.textContent = radio.playing ? '■' : '▶'; t.title = radio.playing ? 'Stopp' : 'Abspielen'; setArt(mini.querySelector('.nui-pod-art'), radio.logo); mini.querySelector('.nui-pod-mini-bar span').style.width = radio.playing ? '100%' : '0'; return; } if (!isPod) return; mini.querySelector('.nui-mini-station').textContent = player.title || ''; // The back button names the set skip length (checked now and then, it rarely changes). if (!mini.skipChecked || Date.now() - mini.skipChecked > 5000) { mini.skipChecked = Date.now(); const sk = mini.querySelector('.nui-pod-skip'); sk.title = `${devicePrefs().skip_back || 15} s zurück`; sk.setAttribute('aria-label', sk.title); } mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …' : (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = player.playing ? '❚❚' : '▶'; t.title = player.playing ? 'Pause' : 'Abspielen'; setArt(mini.querySelector('.nui-pod-art'), player.artwork); mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0'; } let sheet = null; let sheetEpisode = 0; let seeking = false; // ---- chapters and shownotes (server: /podcasts/episodes//) ------------------------ // Fetched per episode when wanted and kept for offline use (the last 40). const details = new Map(); function storedDetails(id) { if (details.has(id)) return details.get(id); try { const d = JSON.parse(localStorage.getItem('nui_pod_ep_' + id) || 'null'); if (d) details.set(id, d); return d; } catch (e) { return null; } } function keepDetails(id, d) { details.set(id, d); try { const ids = JSON.parse(localStorage.getItem('nui_pod_ep_ids') || '[]').filter(x => x !== id); ids.push(id); while (ids.length > 40) localStorage.removeItem('nui_pod_ep_' + ids.shift()); localStorage.setItem('nui_pod_ep_' + id, JSON.stringify(d)); localStorage.setItem('nui_pod_ep_ids', JSON.stringify(ids)); } catch (e) {} } async function loadDetails(id) { try { const r = await fetch(`/podcasts/episodes/${id}/`); if (!r.ok) throw new Error(r.status); const d = await r.json(); keepDetails(id, d); return d; } catch (e) { return storedDetails(id); } } // Timestamps in shownotes ("12:34", "1:02:03") become links that jump there. const STAMP = /\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b/g; function linkStamps(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) { const n = walker.currentNode; if (!n.parentElement.closest('a') && STAMP.test(n.nodeValue)) nodes.push(n); STAMP.lastIndex = 0; } for (const n of nodes) { const frag = document.createDocumentFragment(); let last = 0; n.nodeValue.replace(STAMP, (m, h, mi, se, at) => { const secs = (parseInt(h || '0', 10) * 3600) + parseInt(mi, 10) * 60 + parseInt(se, 10); if (parseInt(se, 10) > 59) return m; frag.append(document.createTextNode(n.nodeValue.slice(last, at))); const a = el('a', 'nui-pod-stamp', m); a.href = '#'; a.dataset.t = String(secs); frag.append(a); last = at + m.length; return m; }); frag.append(document.createTextNode(n.nodeValue.slice(last))); n.replaceWith(frag); } } // Chapters and shownotes into a box; with `live`, taps jump in the playing episode. async function showDetails(box, id, live) { box.textContent = ''; box.dataset.episode = String(id); let d = storedDetails(id); if (!d) box.append(el('div', 'nui-pod-meta', 'Shownotes werden geladen …')); const fresh = await loadDetails(id); if (box.dataset.episode !== String(id)) return; d = fresh || d; box.textContent = ''; if (!d) { box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.')); return; } if (live && sheet) { const fav = sheet.querySelector('.nui-pod-fav'); if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); } } const chapters = d.chapters || []; // The full player lists the chapters right under the title (chapterBox); here only the notes. const chapBox = live && sheet ? sheet.querySelector('.nui-pod-chapbox') : null; if (chapBox) chapterBox(chapBox, chapters, id); const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-notesbody'); const show = which => { tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which)); body.textContent = ''; if (which === 'chapters') { const ol = el('ol', 'nui-pod-chapters'); chapters.forEach((c, i) => { const li = el('li', 'nui-pod-chapter'); li.dataset.start = String(c.start || 0); li.dataset.index = String(i); li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`))); if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000))); ol.append(li); }); body.append(ol); markChapter(box, (player.pos || 0) / 1000); } else if (which === 'transcript') { showTranscript(body, id, live); } else { const notes = el('div', 'nui-pod-notes'); notes.innerHTML = d.description || '

Keine Shownotes.

'; notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank')); if (live) { linkStamps(notes); notes.addEventListener('click', ev => { const a = ev.target.closest('a.nui-pod-stamp'); if (!a) return; ev.preventDefault(); N.podcastSeek(parseInt(a.dataset.t, 10) * 1000); }); } body.append(notes); } }; const tab = (which, label) => { const b = el('button', 'nui-pod-subtab', label); b.type = 'button'; b.dataset.which = which; b.addEventListener('click', () => show(which)); tabs.append(b); }; if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`); tab('notes', 'Shownotes'); if (d.has_transcript) tab('transcript', 'Transkript'); box.append(tabs, body); show(chapters.length && !chapBox ? 'chapters' : 'notes'); } // The chapters in the full player: which one plays, previous / next (like AntennaPod's // chapter buttons), and the list to tap; it keeps the playing chapter in view. function chapterBox(box, chapters, id) { box.textContent = ''; box.dataset.episode = String(id); box.hidden = !chapters.length; // A smaller cover makes room for them. if (box.parentElement) box.parentElement.classList.toggle('nui-pod-withchapters', !!chapters.length); if (!chapters.length) return; const jump = dir => { const t = (player.pos || 0) / 1000; let i = chapters.length - 1; while (i > 0 && chapters[i].start > t + 0.5) i--; // "Back" within the first seconds of a chapter goes to the one before. const target = dir > 0 ? i + 1 : (t - chapters[i].start < 3 ? i - 1 : i); if (target < 0 || target >= chapters.length) return; N.podcastSeek(Math.round(chapters[target].start * 1000)); }; const head = el('div', 'nui-pod-chaphead'); head.append(el('span', 'nui-pod-chapcount', `${chapters.length} Kapitel`)); if (chapters.length > 1) { head.append(button('⏮︎', 'Voriges Kapitel', () => jump(-1), 'nui-pod-chapbtn'), button('⏭︎', 'Nächstes Kapitel', () => jump(1), 'nui-pod-chapbtn')); } const ol = el('ol', 'nui-pod-chapters nui-pod-chaplist'); chapters.forEach((c, i) => { const li = el('li', 'nui-pod-chapter'); li.dataset.start = String(c.start || 0); li.dataset.index = String(i); li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`))); li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000))); ol.append(li); }); box.append(head, ol); markChapter(box, (player.pos || 0) / 1000); } function markChapter(scope, seconds) { const items = scope.querySelectorAll('.nui-pod-chapter'); let current = -1; items.forEach((li, i) => { if (parseFloat(li.dataset.start) <= seconds + 0.5) current = i; }); items.forEach((li, i) => li.classList.toggle('current', i === current)); const count = scope.querySelector('.nui-pod-chapcount'); const list = scope.querySelector('.nui-pod-chaplist'); if (count && list) { const n = list.children.length; count.textContent = current >= 0 ? `Kapitel ${current + 1} von ${n}` : `${n} Kapitel`; // The playing chapter stays in view in the short list (unless it was just scrolled by hand). if (current >= 0 && list.dataset.shown !== String(current) && Date.now() - (list.touched || 0) > 6000) { list.dataset.shown = String(current); const li = list.children[current]; list.scrollTop = Math.max(0, li.offsetTop - list.offsetTop - li.offsetHeight); } if (!list.watched) { list.watched = true; list.addEventListener('touchstart', () => { list.touched = Date.now(); }, {passive: true}); list.addEventListener('wheel', () => { list.touched = Date.now(); }, {passive: true}); } } markTranscript(scope, seconds); } // ---- transcript: follows the playback, a tap jumps there -------------------------------- const transcripts = new Map(); let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while async function showTranscript(body, id, live) { body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …')); let t = transcripts.get(id); if (!t) { try { const r = await fetch(`/podcasts/episodes/${id}/transcript/`); if (!r.ok) throw new Error(r.status); t = await r.json(); transcripts.set(id, t); } catch (e) { body.textContent = ''; body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Für diese Folge gibt es kein lesbares Transkript.' : 'Das Transkript braucht eine Verbindung.')); return; } } body.textContent = ''; const list = el('div', 'nui-pod-transcript'); for (const seg of t.segments || []) { const p = el('p', 'nui-pod-seg'); p.dataset.start = String(seg.start || 0); if (seg.speaker) p.append(el('strong', 'nui-pod-speaker', seg.speaker + ': ')); p.append(document.createTextNode(seg.text || '')); if (live) p.addEventListener('click', () => N.podcastSeek(Math.round((seg.start || 0) * 1000))); list.append(p); } list.addEventListener('touchstart', () => { transcriptTouched = Date.now(); }, {passive: true}); body.append(list); markTranscript(body, (player.pos || 0) / 1000); } function markTranscript(scope, seconds) { const segs = scope.querySelectorAll('.nui-pod-seg'); if (!segs.length) return; let current = null; segs.forEach(p => { if (parseFloat(p.dataset.start) <= seconds + 0.3) current = p; }); segs.forEach(p => p.classList.toggle('current', p === current)); if (current && current.dataset.shown !== '1' && Date.now() - transcriptTouched > 8000) { segs.forEach(p => { p.dataset.shown = ''; }); current.dataset.shown = '1'; current.scrollIntoView({block: 'center', behavior: 'smooth'}); } } // Shownotes of an episode that isn't playing: its own sheet. let notesSheet = null; function openNotes(e) { closeNotes(); notesSheet = el('div'); notesSheet.id = 'nui-pod-player'; notesSheet.className = 'nui-pod-notes-sheet'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeNotes)); panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || '')); const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen'); play1.type = 'button'; play1.addEventListener('click', () => { closeNotes(); play(e); }); panel.append(play1); const box = el('div', 'nui-pod-notesbox'); panel.append(box); notesSheet.append(panel); notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); document.body.append(notesSheet); showDetails(box, e.id, false); } function closeNotes() { if (!notesSheet) return false; notesSheet.remove(); notesSheet = null; return true; } // The full player: one for both. Which one it shows now. let sheetKind = ''; function openPlayer() { if (remote()) { openRemotePlayer(); return; } if (player.kind !== 'podcast') return; closePlayer(); sheetKind = 'podcast'; sheet = el('div'); sheet.id = 'nui-pod-player'; sheet.innerHTML = ''; const panel = sheet.firstChild; const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Player schließen', closePlayer)); panel.append(top, art(player.artwork, 'player', null, player.feed)); const chapBox = el('div', 'nui-pod-chapbox'); chapBox.hidden = true; panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox); const range = el('input', 'nui-pod-seek'); range.type = 'range'; range.min = 0; range.step = 1; range.id = 'nui-pod-seek'; range.addEventListener('input', () => { seeking = true; sheet.querySelector('.nui-pod-time-pos').textContent = clock(shown(range.value, 0, player.speed || 1)); }); range.addEventListener('change', () => { N.podcastSeek(range.value * 1000); seeking = false; }); const times = el('div', 'nui-pod-times'); times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left')); panel.append(range, times); const prefs = devicePrefs(); const back = prefs.skip_back || 15; const fwd = prefs.skip_forward || 30; const controls = el('div', 'nui-pod-controls'); controls.append( button(String(back), `${back} Sekunden zurück`, () => N.podcastSkip(false), 'nui-pod-back'), button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-pod-play'), button(String(fwd), `${fwd} Sekunden vor`, () => N.podcastSkip(true), 'nui-pod-fwd'), ); panel.append(controls); const extra = el('div', 'nui-pod-extra'); extra.append(...[ button('1×', 'Geschwindigkeit', speedMenu, 'nui-pod-speed'), button('☾', 'Sleep-Timer', sleepMenu, 'nui-pod-sleep'), button('☆', 'Favorit', () => toggleFavorite({id: player.episodeId}, star => { const b = sheet && sheet.querySelector('.nui-pod-fav'); if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); } }), 'nui-pod-fav'), button('⏭', keepSkipped() ? 'Nächste Folge (diese bleibt in der Warteschlange)' : 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'), N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'), button('≡', 'Warteschlange', showQueue), ].filter(Boolean)); panel.append(extra); const notes = el('div', 'nui-pod-notesbox'); panel.append(notes); sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); sheetEpisode = player.episodeId; showDetails(notes, player.episodeId, true); renderPlayer(); } function closePlayer() { if (!sheet) return false; sheet.remove(); sheet = null; return true; } // The radio in the same player: the station instead of the episode, "Live" // instead of the seek bar, save / stop / sleep timer as the buttons. function openRadioPlayer() { const r = radioNow(); if (!r) return; closePlayer(); sheetKind = 'radio'; sheet = el('div'); sheet.id = 'nui-pod-player'; sheet.innerHTML = ''; const panel = sheet.firstChild; const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Player schließen', closePlayer)); const cover = art(r.logo, 'player'); cover.classList.add('nui-radio-art'); panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), el('div', 'nui-radio-live')); const controls = el('div', 'nui-pod-controls'); controls.append( button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-pod-fav nui-radio-save'), button('▶', 'Abspielen', () => window.__dioraRadioToggle && window.__dioraRadioToggle(), 'nui-pod-play'), button('☾', 'Sleep-Timer', () => sleepMenu(true), 'nui-pod-sleep'), ); const extra = el('div', 'nui-pod-extra'); const stations = el('button', 'btn nui-radio-stations', 'Sender wechseln'); stations.type = 'button'; stations.addEventListener('click', () => { closePlayer(); showTab('radio'); }); extra.append(stations); if (N.playerTransfer) extra.append(devicesButton()); panel.append(controls, extra); sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); renderRadioPlayer(); } function renderRadioPlayer() { const r = radioNow(); if (!r) { closePlayer(); return; } sheet.querySelector('.nui-pod-player-title').textContent = r.name; sheet.querySelector('.nui-pod-player-feed').textContent = r.track || ''; const live = sheet.querySelector('.nui-radio-live'); live.textContent = r.buffering ? 'Verbinde …' : r.playing ? '● Live' : 'Angehalten'; live.classList.toggle('on', r.playing && !r.buffering); const p = sheet.querySelector('.nui-pod-play'); p.textContent = r.playing ? '■' : '▶'; p.title = r.playing ? 'Stopp' : 'Abspielen'; const s = sheet.querySelector('.nui-radio-save'); s.textContent = r.saved ? '★' : '☆'; s.classList.toggle('on', r.saved); s.disabled = r.saved; s.title = r.saved ? 'Gespeichert' : 'Sender speichern'; const sl = sheet.querySelector('.nui-pod-sleep'); sl.textContent = sleepLabel() || '☾'; sl.classList.toggle('on', !!sleepLabel()); setArt(sheet.querySelector('.nui-pod-art-player'), r.logo); } // native-radio.js: the station, its track or its state changed. window.__dioraRadioChanged = function () { renderMini(); if (sheet && sheetKind === 'radio') renderRadioPlayer(); }; window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? openRadioPlayer() : openPlayer()); function renderPlayer() { if (!sheet) return; const r = remote(); if (sheetKind === 'remote') { // Back here (taken over) or the other device went away: this device's player. if (!r) { closePlayer(); if (player.kind === 'podcast') openPlayer(); else if (radioNow()) openRadioPlayer(); } else if (sheet.dataset.remote !== remoteKey(r)) { openRemotePlayer(); } else { renderRemotePlayer(); } return; } if (r) { openRemotePlayer(); return; } if (sheetKind === 'radio') { if (player.kind === 'podcast') closePlayer(); else renderRadioPlayer(); return; } if (player.kind !== 'podcast') { closePlayer(); return; } if (sheetEpisode !== player.episodeId) { // The next episode started while the player was open. sheetEpisode = player.episodeId; showDetails(sheet.querySelector('.nui-pod-notesbox'), player.episodeId, true); } markChapter(sheet, (player.pos || 0) / 1000); sheet.querySelector('.nui-pod-player-title').textContent = player.title || ''; sheet.querySelector('.nui-pod-player-feed').textContent = player.feed || ''; const range = sheet.querySelector('.nui-pod-seek'); const dur = Math.round((player.dur || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000); range.max = dur || 1; // At the speed, when so set: the place and what's left as long as they take. const at = s => shown(s, 0, player.speed || 1); if (!seeking) { range.value = pos; sheet.querySelector('.nui-pod-time-pos').textContent = clock(at(pos)); } sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(at(dur - pos)) : ''; const p = sheet.querySelector('.nui-pod-play'); p.textContent = player.playing ? '❚❚' : '▶'; p.title = player.playing ? 'Pause' : 'Abspielen'; const sp = sheet.querySelector('.nui-pod-speed'); sp.textContent = speedLabel(player.speed || 1) + (player.podcastSpeed ? '•' : ''); sp.title = player.podcastSpeed ? 'Geschwindigkeit (eigene für diesen Podcast)' : 'Geschwindigkeit'; const sl = sheet.querySelector('.nui-pod-sleep'); sl.textContent = sleepLabel() || '☾'; sl.classList.toggle('on', !!sleepLabel()); const img = sheet.querySelector('.nui-pod-art-player img'); const src = (player.artwork || '').replace(/^http:/, 'https:'); if (img && src && img.getAttribute('src') !== src) img.src = src; } function speedLabel(s) { return String(Math.round(s * 100) / 100).replace('.', ',') + '×'; } // Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast. // The presets, and the speed now if it isn't one of them (a podcast's own 3,5× …). function speedChoices(now) { const list = presets().slice(); if (now > 0 && !list.some(s => Math.abs(s - now) < 0.01)) list.push(now); return list.sort((a, b) => a - b); } function presetsEditItem() { return 'speed_presets' in settings() ? [{icon: '✎', label: 'Voreinstellungen bearbeiten', hint: 'hinzufügen, entfernen', action: () => presetsEditor()}] : []; } function speedMenu() { const own = !!player.podcastSpeed; const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts', speedChoices(own === forPodcast ? player.speed || 1 : 0).map(s => ({ icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○', label: speedLabel(s), action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)), })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []) .concat(presetsEditItem())); if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; } openSheet('Geschwindigkeit', [ {icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)}, {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene', action: () => pick(true)}, ].concat(presetsEditItem())); } // ---- sleep timer (runs in the app, also with the screen off) ---- function sleepLabel() { const t = player.sleep || {}; if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`; if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`; return ''; } // Minutes count only while playing; shaking the phone starts them over. function sleepMenu(radio) { if (!N.podcastSleep) return; const t = player.sleep || {}; const on = !!sleepLabel(); const items = []; if (on) { items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)}); if (t.leftMs > 0 && N.podcastSleepExtend) { items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)}); items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)}); } } for (const m of [5, 10, 15, 30, 45, 60, 90]) { items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)}); } if (radio !== true) { items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); } if (N.podcastSleepAuto) { items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)', hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus', action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }}); } openSheet('Sleep-Timer', items); } // ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept // unplayed in the queue, or played. With nothing after it, it only counts as played // when skipped episodes aren't kept. function finishAndNext() { const id = currentId(); if (!id) return; const ids = queue().map(q => q.episode__id); const at = ids.indexOf(id); const last = at < 0 ? !ids.length : at === ids.length - 1; N.podcastNext(); if (last && !keepSkipped()) N.podcastMarkPlayed(id, true); else if (last) toast('Das ist die letzte Folge der Warteschlange'); } function showQueue() { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); } // ---- another device plays (player sync, like Spotify Connect) ------------------------------ // The host (Bridge.kt / podcasts-web.js) shares one player among the user's // devices. While another device is the active one (online, playing or // paused), player.remote is what it plays: the bar and the player show it, // their buttons control it (N.playerRemote), "Hier abspielen" plays it here // from its place, and the device sheet moves it to any device that is online. function remote() { return player.remote && N.playerRemote ? player.remote : null; } function remoteName(r) { return r.device || 'einem anderen Gerät'; } function remoteKey(r) { return r.kind + ':' + (r.kind === 'radio' ? (r.station && r.station.url) || '' : r.episodeId); } function remoteLine(r) { return (r.playing ? '▸ läuft auf ' : '‖ pausiert auf ') + remoteName(r); } function remoteCmd(action, value) { if (N.playerRemote) N.playerRemote(action, Math.round(value || 0)); } function remoteToggle() { const r = remote(); if (r) remoteCmd(r.playing ? 'pause' : 'play'); } function takeOver() { if (N.playerTakeOver) N.playerTakeOver(); } // Which device plays: the ones online, this one first; choosing one moves the playback there. function deviceMenu() { if (!N.playerTransfer) return; const all = (player.devices || []).filter(d => d.online || d.me); const devices = all.filter(d => d.me).concat(all.filter(d => !d.me)); const r = remote(); const radio = r ? null : radioNow(); const activePlays = r ? r.playing : player.kind === 'podcast' ? !!player.playing : !!(radio && radio.playing); const items = devices.map(d => ({ icon: d.active ? '◉' : '○', label: d.me ? 'Dieses Gerät' : d.name || 'Unbekanntes Gerät', hint: d.active ? (activePlays ? 'spielt gerade' : 'pausiert') : d.me ? null : 'hierhin verschieben', active: !!d.active, action: () => N.playerTransfer(d.id), })); if (devices.length < 2) { items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote', 'Keine anderen Geräte online. Sobald diora auf einem anderen Gerät offen ist, erscheint es hier.')}); } openSheet('Wiedergabe auf …', items); } function devicesButton() { const b = el('button', 'btn nui-pod-devbtn', '⇄ Wiedergabe auf …'); b.type = 'button'; b.addEventListener('click', deviceMenu); return b; } // The full player for what another device plays: the same layout, remote buttons. function openRemotePlayer() { const r = remote(); if (!r) return; closePlayer(); sheetKind = 'remote'; const onRadio = r.kind === 'radio'; sheet = el('div'); sheet.id = 'nui-pod-player'; sheet.className = 'nui-pod-remote'; sheet.dataset.remote = remoteKey(r); sheet.innerHTML = ''; const panel = sheet.firstChild; const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Player schließen', closePlayer)); const cover = art(onRadio ? r.station && r.station.logo : r.artwork, 'player'); if (onRadio) cover.classList.add('nui-radio-art'); panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed nui-pod-remote-line')); const controls = el('div', 'nui-pod-controls'); if (onRadio) { controls.append(button('▶', 'Abspielen', remoteToggle, 'nui-pod-play')); } else { panel.append(el('div', 'nui-pod-player-feed nui-pod-remote-feed')); const range = el('input', 'nui-pod-seek'); range.type = 'range'; range.min = 0; range.step = 1; range.addEventListener('input', () => { seeking = true; sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value); }); range.addEventListener('change', () => { remoteCmd('seek', range.value * 1000); seeking = false; }); const times = el('div', 'nui-pod-times'); times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left')); panel.append(range, times); const prefs = devicePrefs(); const back = prefs.skip_back || 15; const fwd = prefs.skip_forward || 30; controls.append( button(String(back), `${back} Sekunden zurück`, () => remoteCmd('skip', -back), 'nui-pod-back'), button('▶', 'Abspielen', remoteToggle, 'nui-pod-play'), button(String(fwd), `${fwd} Sekunden vor`, () => remoteCmd('skip', fwd), 'nui-pod-fwd'), ); } panel.append(controls); if (N.playerTakeOver) { const here = el('button', 'btn nui-pod-here', '⇩ Hier abspielen'); here.type = 'button'; here.addEventListener('click', takeOver); panel.append(here); } const extra = el('div', 'nui-pod-extra'); if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next')); if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices')); if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue)); panel.append(extra); if (!onRadio && r.episodeId) { const notes = el('div', 'nui-pod-notesbox'); panel.append(notes); showDetails(notes, r.episodeId, false); } sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); renderRemotePlayer(); } function renderRemotePlayer() { const r = remote(); if (!r || !sheet) return; const onRadio = r.kind === 'radio'; sheet.querySelector('.nui-pod-player-title').textContent = (onRadio ? r.station && r.station.name : r.title) || ''; sheet.querySelector('.nui-pod-remote-line').textContent = remoteLine(r); const p = sheet.querySelector('.nui-pod-play'); p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶'; p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r); if (onRadio) return; sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || ''; const range = sheet.querySelector('.nui-pod-seek'); const dur = Math.round((r.dur || 0) / 1000); const pos = Math.round((r.pos || 0) / 1000); range.max = dur || 1; if (!seeking) { range.value = pos; sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos); } sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : ''; markChapter(sheet, pos); } // ---- downloads (Downloads.kt) ------------------------------------------------------------ // Episodes kept on the phone to play without a connection. Each episode row // gets a mark (↓ fetch it, a ring while it loads, ✓ when it's here), the ⋯ // menus and the settings sheet get their entries. The app downloads the // first episodes of the queue by itself and tells us via __dioraDownloads. const DL = !!N.podcastDownloads; let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; // All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {}; // How many of the queue's first episodes load by themselves; 0 = all of them. const DL_COUNTS = [1, 2, 3, 5, 10, 0]; const dlCountLabel = n => (n === 0 ? 'Alle' : n === 1 ? '1 Folge' : n + ' Folgen'); const DL_PARALLEL = [1, 2, 3, 4]; // Why a download waits (DownloadPlan.waitReason). const DL_REASONS = { paused: 'pausiert', wifi: 'wartet auf WLAN', offline: 'wartet auf eine Verbindung', queue: 'in der Reihe', retry: 'neuer Versuch', }; // The limit and the clean-up the AntennaPod way (Downloads.kt, DownloadPlan.toClean). const DL_LIMITS = [5, 10, 25, 50, 100, 500, 0]; const DL_CLEANUPS = [ {value: 'queue', label: 'Wenn Platz gebraucht wird', hint: 'beim Limit gehen Folgen, die nicht in der Warteschlange stehen: gehörte zuerst'}, {value: 'never', label: 'Nie', hint: 'beim Limit lädt die App nichts mehr von selbst'}, ]; const dlLimitLabel = n => (n ? n + ' Folgen' : 'Unbegrenzt'); function size(bytes) { if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB'; return Math.max(1, Math.round((bytes || 0) / 1048576)) + ' MB'; } function dlStart(e) { N.podcastDownload(JSON.stringify(e.raw)); window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'}})); toast('Wird heruntergeladen'); } function dlDelete(e, running, quiet) { N.podcastDeleteDownload(e.id); // "Löschen nimmt aus der Warteschlange" (account): the app takes it out too. const out = !running && settings().delete_dequeue !== false && 'delete_dequeue' in settings() && inQueue(e.id); if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht'); } // The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here. function dlButton(e) { const d = downloads[e.id]; const state = d ? d.state : null; let b; if (state === 'done') { b = button('✓', 'Heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-done'); } else if (state === 'queued' || state === 'downloading') { b = button('', state === 'queued' ? 'Wartet auf den Download' : 'Wird heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-ring'); b.append(el('span')); } else { b = button('↓', state === 'failed' ? 'Download hat nicht geklappt — nochmal' : 'Herunterladen', () => dlStart(e), 'nui-pod-dl' + (state === 'failed' ? ' nui-pod-dl-failed' : '')); } b.dataset.dl = e.id; dlRing(b); return b; } // Elsewhere a small mark in front of the length: an arrow in a circle when it's here, // a filling ring while it loads, ! when it failed. function dlMark(e) { if (!DL) return null; const d = downloads[e.id]; if (!d) return null; const m = el('span', 'nui-pod-dlmark nui-pod-dlmark-' + d.state); m.dataset.dlmark = e.id; m.title = d.state === 'done' ? 'Heruntergeladen' : d.state === 'failed' ? 'Download hat nicht geklappt' : 'Wird heruntergeladen'; m.textContent = d.state === 'done' ? '↓' : d.state === 'failed' ? '!' : ''; dlRing(m); return m; } function dlRing(b) { const d = downloads[b.dataset.dl || b.dataset.dlmark]; if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return; b.style.setProperty('--p', (d.progress || 0) + '%'); if (b.dataset.dl) b.firstChild.textContent = d.state === 'queued' ? (d.reason === 'paused' ? '‖' : '…') : d.progress + ''; } // "wartet auf WLAN", "neuer Versuch um 14:05" … function dlReason(d) { if (d.reason === 'retry' && d.retryAt) { return 'neuer Versuch um ' + new Date(d.retryAt).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}); } return DL_REASONS[d.reason] || 'wartet'; } function dlMenuItems(e) { if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return []; const d = downloads[e.id]; if (!d || d.state === 'failed') { return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung', action: () => dlStart(e)}]; } if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}]; return [ d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst && {icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)}, {icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? dlReason(d) : d.progress + ' %', action: () => dlDelete(e, true)}, ]; } function dlSet(changes) { Object.assign(dlSettings, changes); N.podcastSetDownloadSettings(JSON.stringify(changes)); settingsMenu(); } function dlSettingsItems() { if (!DL) return []; const s = dlSettings = parse(N.podcastDownloadSettings(), dlSettings); const done = Object.values(downloads).filter(d => d.state === 'done'); const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0); return [ {icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen', hint: s.auto ? (s.count === 0 ? 'die ganze Warteschlange' : `die ersten ${s.count} Folgen der Warteschlange`) : 'aus', action: () => dlSet({auto: !s.auto})}, s.auto && {icon: '#', label: 'Wie viele Folgen', hint: dlCountLabel(s.count), action: () => openSheet('Automatisch herunterladen', DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: dlCountLabel(n), hint: n === 0 ? 'die ganze Warteschlange' : null, active: s.count === n, action: () => dlSet({count: n})})))}, s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN', action: () => dlSet({mobile: !s.mobile})}, s.parallel != null && {icon: '⇉', label: 'Gleichzeitig laden', hint: s.parallel === 1 ? '1 Folge' : s.parallel + ' Folgen', action: () => openSheet('Gleichzeitig laden', DL_PARALLEL.map(n => ({ icon: s.parallel === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.parallel === n, hint: n === 3 ? 'Standard' : null, action: () => dlSet({parallel: n})})))}, {icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen', hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten', action: () => dlSet({deletePlayed: !s.deletePlayed})}, s.limit != null && {icon: '▦', label: 'Höchstens behalten', hint: dlLimitLabel(s.limit) + ` · jetzt ${Object.values(downloads).filter(d => d.state !== 'failed').length}`, action: () => openSheet('Höchstens so viele Folgen auf dem Telefon', DL_LIMITS.map(n => ({ icon: s.limit === n ? '●' : '○', label: dlLimitLabel(n), active: s.limit === n, hint: n === 25 ? 'Standard' : null, action: () => dlSet({limit: n})})))}, 'enqueue' in s && {icon: s.enqueue ? '☑' : '☐', label: 'Heruntergeladene Folgen einreihen', hint: s.enqueue ? 'an: was geladen wird, kommt in die Warteschlange' : 'aus', action: () => dlSet({enqueue: !s.enqueue})}, s.cleanup && {icon: '♻', label: 'Aufräumen', hint: (DL_CLEANUPS.find(c => c.value === s.cleanup) || DL_CLEANUPS[0]).label, action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({ icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value, action: () => dlSet({cleanup: c.value})})))}, done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen', action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [ {icon: '✕', label: 'Löschen', hint: size(bytes), danger: true, action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }}, {icon: '‹', label: 'Abbrechen', action: () => {}}, ])}, ]; } window.__dioraDownloads = function (map, info) { const before = downloads; downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {}; if (info) dlInfo = typeof info === 'string' ? parse(info, dlInfo) : info; const keys = Object.keys(downloads); const same = keys.length === Object.keys(before).length && keys.every(id => before[id] && before[id].state === downloads[id].state && before[id].reason === downloads[id].reason); // Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going). if (same) { document.querySelectorAll('#nui-pod [data-dl], #nui-pod [data-dlmark]').forEach(dlRing); dlIndicatorText(); } else { render(); } renderDownloads(); }; function dlCounts() { const c = {running: 0, waiting: 0, failed: 0, done: 0, bytes: 0}; for (const d of Object.values(downloads)) { if (d.state === 'downloading') c.running++; else if (d.state === 'queued') c.waiting++; else if (d.state === 'failed') c.failed++; else if (d.state === 'done') { c.done++; c.bytes += d.bytes || 0; } } return c; } function dlSummary() { const c = dlCounts(); const parts = []; if (c.running) parts.push(c.running + (c.running === 1 ? ' läuft' : ' laufen')); if (c.waiting) parts.push(c.waiting + (c.waiting === 1 ? ' wartet' : ' warten') + (dlInfo.paused ? ' (pausiert)' : '')); if (c.failed) parts.push(c.failed + ' fehlgeschlagen'); if (c.done) parts.push(c.done + ' fertig'); return parts.join(' · ') || 'keine'; } // The top bar's sign while downloads run or wait: "↓ 3/68" (the one loading of all in this round, // like the notification), "‖ 68" while paused. Opens the overview. function dlIndicator() { if (!DL || !N.podcastDownloadsInfo) return null; const b = button('', 'Downloads', openDownloads, 'nui-pod-dlind'); b.id = 'nui-pod-dlind'; dlIndicatorText(b); return b; } function dlIndicatorText(b) { b = b || $id('nui-pod-dlind'); if (!b) return; const c = dlCounts(); const busy = c.running + c.waiting; b.hidden = !busy; if (!busy) return; const total = Math.max(dlInfo.total || 0, busy); const done = Math.min(dlInfo.done || 0, total); b.textContent = dlInfo.paused ? '‖ ' + busy : c.running ? `↓ ${Math.min(done + 1, total)}/${total}` : '↓ ' + busy; b.title = 'Downloads: ' + dlSummary(); b.setAttribute('aria-label', b.title); } // ---- the downloads overview: what runs, waits, failed, is done --------------------------- let dlSheet = null; let dlBody = null; function closeDownloads() { if (!dlSheet) return false; dlSheet.remove(); dlSheet = dlBody = null; return true; } function openDownloads() { if (!DL) return; closeDownloads(); dlSheet = el('div'); dlSheet.id = 'nui-pod-player'; dlSheet.className = 'nui-pod-notes-sheet nui-pod-dls'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeDownloads)); dlBody = el('div', 'nui-pod-dls-body'); panel.append(top, el('div', 'nui-pod-player-title', 'Downloads'), dlBody); dlSheet.append(panel); dlSheet.addEventListener('click', ev => { if (ev.target === dlSheet) closeDownloads(); }); document.body.append(dlSheet); renderDownloads(true); } // The finished downloads: the episode list with the filter „Geladen“ (▶ plays, swipes delete). function showDownloaded() { closeDownloads(); closePlayer(); selected = null; feedView = null; preview = null; view = 'episodes'; try { localStorage.setItem('nui_pod_view', view); } catch (e) {} if (typeof showTab === 'function') showTab('podcasts'); setEpisodeList(['downloaded'], 'new'); scrollTop(0); } function dlTitle(d) { return plain(d.title) || 'Folge ' + d.id; } // The line under the title: how far, why it waits, what went wrong. function dlStatus(d) { const of = d.total ? `${size(d.bytes)} von ${size(d.total)}` : size(d.bytes); if (d.state === 'downloading') return (d.total ? d.progress + ' % · ' : '') + of; if (d.state === 'failed') return d.error || 'hat nicht geklappt'; const parts = [dlReason(d)]; if (d.bytes) parts.push(of + ' geladen'); if (d.reason === 'retry' && d.error) parts.push(d.error); return parts.join(' · '); } function dlActions(d) { const items = []; if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) { items.push({icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(d.id)}); } if ((d.state === 'failed' || (d.state === 'queued' && d.error)) && N.podcastRetryDownload) { items.push({icon: '↻', label: 'Erneut versuchen', hint: d.error || null, action: () => N.podcastRetryDownload(d.id)}); } items.push(d.state === 'failed' ? {icon: '✕', label: 'Löschen', action: () => N.podcastDeleteDownload(d.id)} : {icon: '✕', label: 'Abbrechen', hint: d.bytes ? 'was schon geladen ist, geht mit' : null, action: () => N.podcastDeleteDownload(d.id)}); return items; } function dlRow(d) { const row = el('div', 'nui-pod-dlrow' + (d.state === 'failed' ? ' nui-pod-dlrow-failed' : '')); row.dataset.dlrow = d.id; const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', dlTitle(d))); const f = d.feedId && feeds().find(x => x.id === d.feedId); const meta = el('div', 'nui-pod-meta nui-pod-dlstatus'); main.append(meta); if (f) main.append(el('div', 'nui-pod-meta', plain(f.title))); if (d.state === 'downloading' || (d.state === 'queued' && d.bytes)) { const bar = el('div', 'nui-pod-progress nui-pod-stat-bar'); bar.append(el('span')); main.append(bar); } row.append(main); // The likely tap right there; all of them in the row's sheet. if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) { row.append(button('⇈', 'Zuerst laden', () => N.podcastDownloadFirst(d.id))); } if (d.state === 'failed' && N.podcastRetryDownload) { row.append(button('↻', 'Erneut versuchen', () => N.podcastRetryDownload(d.id))); } row.append(button('✕', d.state === 'failed' ? 'Löschen' : 'Abbrechen', () => N.podcastDeleteDownload(d.id))); row.addEventListener('click', () => openSheet(dlTitle(d), dlActions(d))); dlRowNumbers(row, d); return row; } function dlRowNumbers(row, d) { row.querySelector('.nui-pod-dlstatus').textContent = dlStatus(d); const fill = row.querySelector('.nui-pod-progress > span'); if (fill) fill.style.width = (d.total ? Math.round(d.bytes / d.total * 100) : 0) + '%'; } function renderDownloads(force) { if (!dlBody) return; const all = Object.keys(downloads).map(id => Object.assign({id: +id}, downloads[id])); const running = all.filter(d => d.state === 'downloading'); const waiting = all.filter(d => d.state === 'queued').sort((a, b) => (a.order || 0) - (b.order || 0)); const failed = all.filter(d => d.state === 'failed'); const done = all.filter(d => d.state === 'done'); // The same rows as before: only the numbers move (keeps a tap or the scroll going). const sig = [running, waiting, failed].map(l => l.map(d => `${d.id}:${d.reason || ''}:${d.error || ''}`).join(',')) .join('|') + `|${done.length}|${!!dlInfo.paused}`; if (!force && sig === dlBody.dataset.sig) { for (const d of running.concat(waiting)) { const row = dlBody.querySelector(`[data-dlrow="${d.id}"]`); if (row) dlRowNumbers(row, d); } return; } dlBody.dataset.sig = sig; const panel = dlBody.parentElement; const scroll = panel.scrollTop; dlBody.textContent = ''; const head = el('div', 'nui-pod-dls-head'); head.append(el('div', 'nui-pod-summary', dlSummary())); const tools = el('div', 'nui-pod-offer-row'); if (N.podcastPauseDownloads && (running.length || waiting.length || dlInfo.paused)) { const paused = !!dlInfo.paused; tools.append(button(paused ? '▶ Fortsetzen' : '‖ Alle pausieren', paused ? 'Downloads fortsetzen' : 'Alle Downloads pausieren', () => { dlInfo.paused = !paused; N.podcastPauseDownloads(!paused); renderDownloads(true); }, 'btn')); } if (failed.length > 1 && N.podcastRetryDownload) { tools.append(button('↻ Alle erneut', 'Alle fehlgeschlagenen erneut versuchen', () => N.podcastRetryDownload(0), 'btn')); } if (tools.childNodes.length) head.append(tools); dlBody.append(head); const section = (title, list) => { if (!list.length) return; dlBody.append(el('div', 'nui-pod-dls-sec', `${title} (${list.length})`)); const box = el('div', 'nui-pod-list'); for (const d of list) box.append(dlRow(d)); dlBody.append(box); }; section('Läuft', running); section('Wartet', waiting); section('Fehlgeschlagen', failed); if (done.length) { dlBody.append(el('div', 'nui-pod-dls-sec', 'Fertig')); const row = el('div', 'nui-pod-dls-donerow'); row.append(el('div', 'nui-pod-meta nui-pod-dls-done', `${done.length} ${done.length === 1 ? 'Folge' : 'Folgen'} auf dem Telefon · ${size(done.reduce((n, d) => n + (d.bytes || 0), 0))}`), button('Anzeigen ›', 'Die geladenen Folgen als Liste', showDownloaded, 'btn nui-pod-dls-show')); dlBody.append(row); } if (!all.length) dlBody.append(el('div', 'nui-pod-empty', 'Keine Downloads. Beim ↓ einer Folge lädst du sie aufs Telefon.')); panel.scrollTop = scroll; } // ---- hooks from the app --------------------------------------------------------------- // A podcast address from another app is waiting (MainActivity): show its preview. window.__dioraSubscribe = function () { const url = N.podcastTakeSubscribe ? N.podcastTakeSubscribe() : ''; if (url) openPreview(url); }; window.__dioraPreview = function (id, d) { if (!preview || preview.id !== id) return; if (d && d.ok) preview = {id, url: d.url, data: d}; else if (d && d.choices && d.choices.length) preview = {id, url: preview.url, choices: d.choices}; else preview = {id, url: preview.url, error: (d && d.error) || 'Dieser Feed ließ sich nicht laden.'}; render(); }; window.__dioraPodcasts = function (json) { state = typeof json === 'string' ? parse(json, state) : json; moveFeedListsToServer(); render(); }; // The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream). window.__dioraAskStream = function (id, title) { if (!N.podcastAllowStream) return; openSheet('Über mobile Daten streamen?', [ {icon: '▶', label: 'Ja, diese Folge streamen', hint: plain(title) || null, action: () => N.podcastAllowStream(id)}, {icon: '✕', label: 'Nein', hint: 'im WLAN oder heruntergeladen hören', action: () => {}}, ]); }; // A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“). window.__dioraOpenFeed = function (feedId) { if (typeof showTab !== 'function') return; closeStats(); closeDownloads(); closeNotes(); closePlayer(); selected = null; showTab('podcasts'); const f = feeds().find(x => x.id === feedId); if (f) { openFeed(f); } else { feedView = null; view = 'inbox'; render(); scrollTop(0); } }; window.__dioraPodcastsRefreshed = function (ok) { refreshing = false; if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand'); render(); }; window.__dioraPlayer = function (p) { const before = player; player = p || {kind: null}; renderMini(); renderPlayer(); // The current episode changed: the queue view marks it. if (before.episodeId !== player.episodeId || before.kind !== player.kind) render(); else if (!!before.playing !== !!player.playing) playButtons(); }; // The app loads this in ; the browser may load it after the page is parsed. function start(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); } start(() => { if (typeof showTab !== 'function') return; // The web's podcast tab loads its own lists; the app has them already. window.loadPodcastTab = function () {}; window.refreshAllFeeds = async function () {}; build(); buildMini(); player = parse(N.playerState(), {kind: null}); moveFeedListsToServer(); render(); renderMini(); const back = window.__dioraBack; window.__dioraBack = function () { if (closeStats()) return true; if (closeDownloads()) return true; if (closeNotes()) return true; if (closePlayer()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true; if (back && back()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts') { if (closePreview()) return true; if (feedView) { feedView = null; render(); return true; } // From the search back to the list it was opened from. if (view === 'search') { try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) { view = 'queue'; } if (!VIEWS.some(v => v.id === view)) view = 'queue'; render(); return true; } showTab('books'); return true; } return false; }; // A swipe waiting for „Rückgängig“ is done when the page goes away. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') commitSwipe(); }); window.addEventListener('pagehide', commitSwipe); // Pull the latest state once per start; the server has done the fetching. N.podcastRefresh(false); window.__dioraSubscribe(); }); })();