2026-10-02 12:44:49 +02:00
// Copied from diora-android 6c2b071 2026-10-02: web-native/static/native-podcast.js
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// 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 ) ;
const VIEWS = [
{ id : 'queue' , label : 'Warteschlange' } ,
{ id : 'inbox' , label : 'Neu' } ,
{ id : 'episodes' , label : 'Folgen' } ,
{ id : 'feeds' , label : 'Abos' } ,
{ id : 'search' , label : 'Suche' } ,
] ;
const POSITIONS = [
{ value : 'back' , label : 'Ans Ende' } ,
{ value : 'front' , label : 'An den Anfang' } ,
{ value : 'after_current' , label : 'Nach der laufenden Folge' } ,
] ;
const SPEEDS = [ 0.8 , 0.9 , 1 , 1.1 , 1.2 , 1.3 , 1.5 , 1.75 , 2 ] ;
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 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 ) { }
if ( ! VIEWS . some ( v => v . id === view ) ) view = 'queue' ;
const queue = ( ) => state . queue || [ ] ;
const inbox = ( ) => state . inbox || [ ] ;
const feeds = ( ) => state . feeds || [ ] ;
const settings = ( ) => state . settings || { } ;
const currentId = ( ) => ( player . kind === 'podcast' ? player . episodeId : 0 ) ;
// ---- 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 ` ;
}
function day ( iso ) {
if ( ! iso ) return '' ;
const d = new Date ( iso ) ;
if ( isNaN ( d ) ) return '' ;
const diff = Math . floor ( ( Date . now ( ) - d ) / 86400000 ) ;
if ( diff <= 0 ) return 'heute' ;
if ( diff === 1 ) return 'gestern' ;
if ( diff < 7 ) return ` vor ${ diff } Tagen ` ;
return d . toLocaleDateString ( 'de-DE' , { day : 'numeric' , month : 'short' , year : diff > 300 ? 'numeric' : undefined } ) ;
}
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 || o . episode _ _feed _ _artwork _url , 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 || o . feed _ _artwork _url || ( 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 art ( src , size ) {
const box = el ( 'div' , 'nui-pod-art' + ( size ? ' nui-pod-art-' + size : '' ) ) ;
if ( src ) {
const img = el ( 'img' ) ;
img . loading = 'lazy' ;
img . alt = '' ;
img . src = src . replace ( /^http:/ , 'https:' ) ;
img . addEventListener ( 'error' , ( ) => img . remove ( ) ) ;
box . append ( img ) ;
}
return box ;
}
function inQueue ( id ) {
return queue ( ) . some ( q => q . episode _ _id === id ) ;
}
// ---- actions ---------------------------------------------------------------------------
function play ( e ) {
if ( 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' ) ;
}
function queueMenu ( e ) {
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' , danger : true , action : ( ) => N . podcastDequeue ( e . id ) } ,
] ) ;
}
function episodeMenu ( e ) {
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 ] ) ) } ,
] ) ;
}
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' , [
2026-10-02 10:30:00 +02:00
{ icon : '⟳' , label : 'Alle Feeds jetzt abrufen' , hint : 'der Server holt sie, hier kommt nur das Ergebnis an' ,
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ( ) ,
] ) ,
group ( '▶' , 'Abspielen' , [ 'Sprungweiten' , 'skip_silence' in prefs && 'Stille' , '„gehört“' ,
'headset_next' in prefs && 'Kopfhörer' ] . filter ( Boolean ) . join ( ', ' ) , ( ) => [
skipItem ( 'skip_back' , 'Zurückspringen' , [ 5 , 10 , 15 , 30 ] ) ,
skipItem ( 'skip_forward' , 'Vorspringen' , [ 10 , 15 , 30 , 45 , 60 ] ) ,
silenceItem ( ) ,
playedItem ( ) ,
headsetItem ( ) ,
] ) ,
2026-10-02 12:44:49 +02:00
DL && { icon : '↓' , label : 'Downloads' , hint : dlSummary ( ) , action : openDownloads } ,
DL && group ( '⚙︎' , 'Download-Einstellungen' , 'automatisch, gleichzeitig, Limit, aufräumen' , ( ) => [
2026-10-02 11:46:37 +02:00
// 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 ( ) ,
] ) ,
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 } ,
] ) ;
}
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 , choices . map ( c => ( {
icon : c === now ? '●' : '○' , label : ` ${ c } Sekunden ` , action : ( ) => N . podcastSetPrefValue ( name , c ) ,
} ) ) ) } ;
}
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 …' , [ 0 , 15 , 30 , 60 , 120 , 300 , 600 ] . map ( v => ( {
icon : v === now ? '●' : '○' , label : label ( v ) , action : ( ) => N . podcastSetPrefValue ( 'smart_played' , 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 ) } ,
] ) } ;
}
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 ) {
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 ) {
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 ;
}
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 ( ) ;
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' } ) ) ) ;
2026-10-02 12:44:49 +02:00
bar . append ( ... [ text , dlIndicator ( ) , button ( '⟳' , 'Alle Feeds abrufen' , ( ) => refresh ( true ) ) ,
button ( '⋮' , 'Einstellungen' , settingsMenu ) ] . filter ( Boolean ) ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
root . append ( bar ) ;
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' ? inbox ( ) . length : 0 ;
if ( count ) b . append ( el ( 'span' , 'nui-pod-count' , 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 ) ;
2026-10-02 12:44:49 +02:00
const offer = importOffer ( ) ;
if ( offer ) root . append ( offer ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
if ( view === 'queue' ) renderQueue ( ) ;
else if ( view === 'inbox' ) renderInbox ( ) ;
else if ( view === 'episodes' ) renderEpisodes ( ) ;
else if ( view === 'feeds' ) renderFeeds ( ) ;
else renderSearch ( ) ;
}
scrollTop ( scroll ) ;
}
2026-10-02 12:44:49 +02:00
// 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 : 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 ( ) ;
} ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ;
}
function episodeRow ( e , opts ) {
const row = el ( 'div' , 'nui-pod-row' + ( currentId ( ) === e . id ? ' nui-pod-current' : '' ) + ( e . played ? ' nui-pod-played' : '' ) ) ;
row . dataset . id = e . id ;
row . append ( art ( e . art ) ) ;
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 ( e . duration - e . pos ) + ' übrig' : minutes ( e . duration ) ) ;
if ( opts . showDate && e . date ) meta . push ( day ( e . date ) ) ;
if ( opts . showFeed && e . feed ) meta . push ( e . feed ) ;
main . append ( el ( 'div' , 'nui-pod-meta' , meta . join ( ' · ' ) ) ) ;
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 ) ) ;
} else {
dlAppend ( row , e ) ;
row . addEventListener ( 'click' , ( ) => play ( e ) ) ;
}
if ( ! selected && ! opts . noSelect ) {
// Long press starts choosing several, like in AntennaPod.
let timer = 0 ;
row . addEventListener ( 'pointerdown' , ev => {
if ( ev . target . closest ( 'button, .nui-pod-handle' ) ) return ;
timer = setTimeout ( ( ) => { timer = 0 ; startSelecting ( e . id ) ; } , 550 ) ;
} ) ;
const cancel = ( ) => { if ( timer ) clearTimeout ( timer ) ; timer = 0 ; } ;
row . addEventListener ( 'pointerup' , cancel ) ;
row . addEventListener ( 'pointerleave' , cancel ) ;
row . addEventListener ( 'pointercancel' , cancel ) ;
row . addEventListener ( 'contextmenu' , ev => ev . preventDefault ( ) ) ;
}
return row ;
}
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 + Math . max ( 0 , ( e . duration || 0 ) - ( e . pos || 0 ) ) , 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' , danger : true , 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 } ) ;
if ( ! selected ) {
row . append ( button ( '⋯' , 'Mehr' , ( ) => queueMenu ( e ) ) ) ;
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 ( ) ; } } ,
] ) ) ;
} else {
const head = el ( 'div' , 'nui-pod-head' ) ;
head . append ( el ( 'div' , 'nui-pod-summary' , ` ${ items . length } neue ${ items . length === 1 ? 'Folge' : 'Folgen' } ` ) ,
listTools ( [ button ( '☐' , 'Mehrere auswählen' , ( ) => startSelecting ( ) ) ] ) ) ;
root . append ( head ) ;
}
const list = el ( 'div' , 'nui-pod-list' ) ;
for ( const e of items ) {
const row = episodeRow ( e , { showFeed : true , showDate : true } ) ;
if ( ! selected ) {
row . append (
e . inQueue || inQueue ( e . id )
? button ( '✓' , 'In der Warteschlange' , ( ) => toast ( 'Schon in der Warteschlange' ) , 'nui-pod-queued' )
: button ( '+' , 'Einreihen' , ( ) => enqueue ( e ) ) ,
button ( '⋯' , 'Mehr' , ( ) => episodeMenu ( e ) ) ) ;
}
list . append ( row ) ;
}
root . append ( list ) ;
}
function renderFeeds ( ) {
const items = feeds ( ) . slice ( ) . sort ( ( a , b ) => String ( b . latest _episode _at || '' ) . localeCompare ( String ( a . latest _episode _at || '' ) ) ) ;
if ( ! items . length ) {
empty ( 'Noch keine Abos. Unter „Suche“ findest du Podcasts.' ) ;
return ;
}
const list = el ( 'div' , 'nui-pod-list' ) ;
for ( const f of items ) {
const row = el ( 'div' , 'nui-pod-row' ) ;
row . append ( art ( f . artwork _url ) ) ;
const main = el ( 'div' , 'nui-pod-main' ) ;
main . append ( el ( 'div' , 'nui-pod-title' , plain ( f . title ) ) ) ;
const meta = [ ] ;
if ( f . author ) meta . push ( f . author ) ;
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' ) ) ;
row . addEventListener ( 'click' , ( ) => openFeed ( f ) ) ;
list . append ( row ) ;
}
root . append ( list ) ;
}
async function openFeed ( f ) {
feedView = { feed : f , episodes : 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 ( ) ;
}
}
}
function renderFeed ( ) {
const f = feedView . feed ;
const head = el ( 'div' , 'nui-pod-feedhead' ) ;
head . append ( button ( '‹ ' , 'Zurück zu den Abos' , ( ) => { feedView = null ; render ( ) ; } ) , art ( f . artwork _url , 'big' ) ) ;
const info = el ( 'div' , 'nui-pod-main' ) ;
info . append ( el ( 'div' , 'nui-pod-title' , f . title ) ) ;
if ( f . author ) info . append ( el ( 'div' , 'nui-pod-meta' , f . author ) ) ;
head . append ( info ) ;
root . append ( head ) ;
const live = feeds ( ) . find ( x => x . id === f . id ) || f ;
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.' ) ) ;
}
if ( 'new_episodes' in live && N . podcastFeedSettings ) {
root . append ( newEpisodesRow ( live ) ) ;
if ( N . podcastFeedPrefs ) root . append ( feedSettings ( f ) ) ;
root . append ( unsubscribeBox ( f ) ) ;
return renderFeedEpisodes ( f ) ;
}
const auto = live . auto _queue ;
const toggle = el ( 'label' , 'nui-pod-switch' ) ;
const box = el ( 'input' ) ;
box . type = 'checkbox' ;
box . checked = ! ! auto ;
box . addEventListener ( 'change' , ( ) => N . podcastAutoQueue ( f . id , box . checked ) ) ;
const pos = ( POSITIONS . find ( p => p . value === settings ( ) . auto _queue _position ) || POSITIONS [ 1 ] ) . label . toLowerCase ( ) ;
toggle . append ( box , el ( 'span' , null , 'Neue Folgen automatisch einreihen' ) , el ( 'span' , 'nui-pod-meta' , pos ) ) ;
root . append ( toggle ) ;
if ( N . podcastFeedPrefs ) root . append ( feedSettings ( f ) ) ;
root . append ( unsubscribeBox ( f ) ) ;
renderFeedEpisodes ( f ) ;
}
// Where this podcast's new episodes go, and which ones count at all.
function newEpisodesRow ( live ) {
const row = el ( 'div' , 'nui-pod-feedprefs' ) ;
const chip = ( label , value , on , onClick ) => {
const b = el ( 'button' , 'nui-pod-chip' + ( on ? ' on' : '' ) ) ;
b . type = 'button' ;
b . append ( el ( 'span' , null , label ) , el ( 'strong' , null , value ) ) ;
b . addEventListener ( 'click' , onClick ) ;
return b ;
} ;
const save = changes => { N . podcastFeedSettings ( live . id , JSON . stringify ( changes ) ) ; } ;
const where = { inbox : 'in „Neu“' , queue : 'in die Warteschlange' , ignore : 'nirgends' } ;
const pos = ( POSITIONS . find ( p => p . value === settings ( ) . auto _queue _position ) || POSITIONS [ 1 ] ) . label . toLowerCase ( ) ;
row . append ( chip ( 'Neue Folgen' , where [ live . new _episodes ] || where . inbox , live . new _episodes !== 'inbox' , ( ) =>
openSheet ( ` Neue Folgen von „ ${ plain ( live . title ) } “ … ` , [
{ icon : live . new _episodes === 'inbox' ? '●' : '○' , label : 'In „Neu“' , hint : 'du entscheidest selbst' , action : ( ) => save ( { new _episodes : 'inbox' } ) } ,
{ icon : live . new _episodes === 'queue' ? '●' : '○' , label : 'Gleich in die Warteschlange' , hint : pos , action : ( ) => save ( { new _episodes : 'queue' } ) } ,
{ icon : live . new _episodes === 'ignore' ? '●' : '○' , label : 'Nirgends anzeigen' , hint : 'nur in der Folgenliste des Podcasts' , action : ( ) => save ( { new _episodes : 'ignore' } ) } ,
] ) ) ) ;
const words = [ 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 ( ', ' ) ;
const ask = async ( key , title , now ) => {
const v = typeof customPrompt === 'function' ? await customPrompt ( title , now || '' ) : null ;
if ( v != null ) save ( { [ key ] : v . trim ( ) } ) ;
} ;
2026-10-02 11:46:37 +02:00
// AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves.
if ( 'auto_download' in live && N . podcastDownloads ) {
row . append ( chip ( 'Auto-Download' , live . auto _download ? 'an' : 'aus' , ! ! live . auto _download , ( ) =>
openSheet ( ` Neue Folgen von „ ${ plain ( live . title ) } “ herunterladen? ` , [
{ icon : live . auto _download ? '●' : '○' , label : 'Ja, von selbst' , hint : 'im WLAN, innerhalb des Download-Limits' ,
action : ( ) => save ( { auto _download : true } ) } ,
{ icon : ! live . auto _download ? '●' : '○' , label : 'Nein' , hint : 'nur die Warteschlange wird geladen' , action : ( ) => save ( { auto _download : false } ) } ,
] ) ) ) ;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
row . append ( chip ( 'Filter' , words || 'aus' , ! ! words , ( ) => openSheet ( '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 : ( ) => openSheet ( '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' ,
action : ( ) => save ( { min _duration : m } ) ,
} ) ) ) } ,
] ) ) ) ;
return row ;
}
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 ; }
const list = el ( 'div' , 'nui-pod-list' ) ;
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 e = ep ( o ) ;
const row = episodeRow ( e , { showDate : true , noSelect : true } ) ;
row . append (
inQueue ( e . id ) ? button ( '✓' , 'In der Warteschlange' , ( ) => toast ( 'Schon in der Warteschlange' ) , 'nui-pod-queued' )
: button ( '+' , 'Einreihen' , ( ) => { enqueue ( e ) ; o . in _queue = true ; } ) ,
button ( '⋯' , 'Mehr' , ( ) => episodeMenu ( e ) ) ) ;
list . append ( row ) ;
}
root . append ( list ) ;
}
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 and outro to skip.
function feedSettings ( f ) {
const live = feeds ( ) . find ( x => x . id === f . id ) || f ;
const own = '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 row = el ( 'div' , 'nui-pod-feedprefs' ) ;
const chip = ( label , value , onClick ) => {
const b = el ( 'button' , 'nui-pod-chip' + ( value ? ' on' : '' ) ) ;
b . type = 'button' ;
b . append ( el ( 'span' , null , label ) , el ( 'strong' , null , value || 'aus' ) ) ;
b . addEventListener ( 'click' , onClick ) ;
return b ;
} ;
const set = ( key , v ) => { N . podcastFeedPrefs ( f . id , JSON . stringify ( { [ key ] : v } ) ) ; setTimeout ( render , 50 ) ; } ;
const seconds = ( key , title ) => openSheet ( title , [ 0 , 10 , 15 , 20 , 30 , 45 , 60 , 90 , 120 ] . map ( v => ( {
icon : ( own [ key ] || 0 ) === v ? '●' : '○' , label : v ? ` ${ v } Sekunden ` : 'Nichts überspringen' , action : ( ) => set ( key , v ) ,
} ) ) ) ;
row . append (
chip ( 'Tempo' , own . speed ? speedLabel ( own . speed ) : '' , ( ) => openSheet ( ` Geschwindigkeit für „ ${ plain ( f . title ) } “ ` ,
SPEEDS . map ( v => ( { icon : own . speed === v ? '●' : '○' , label : speedLabel ( v ) , action : ( ) => set ( 'speed' , v ) } ) )
. concat ( [ { icon : '↺' , label : 'Wie alle anderen Podcasts' , action : ( ) => set ( 'speed' , 0 ) } ] ) ) ) ,
chip ( 'Intro' , own . intro ? ` ${ own . intro } s ` : '' , ( ) => seconds ( 'intro' , 'Am Anfang jeder Folge überspringen' ) ) ,
chip ( 'Outro' , own . outro ? ` ${ own . outro } s ` : '' , ( ) => seconds ( 'outro' , 'Am Ende jeder Folge überspringen' ) ) ,
chip ( 'Lautstärke' , volumeLabel ( own . volume ) , ( ) => openSheet ( ` Lautstärke für „ ${ plain ( f . 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 , action : ( ) => set ( 'volume' , v ) } ) ) ) ) ,
) ;
return row ;
}
// Unsubscribing (server: POST /podcasts/feeds/<id>/remove/). Asked once more
// right here; then the server forgets the podcast with its episodes, and the
// app deletes their downloads (not those of favourites).
function unsubscribeBox ( f ) {
const box = el ( 'div' , 'nui-pod-unsub' ) ;
if ( ! feedView . confirmRemove ) {
const b = el ( 'button' , 'nui-pod-unsub-btn' , 'Abo beenden' ) ;
b . type = 'button' ;
b . addEventListener ( 'click' , ( ) => {
if ( ! navigator . onLine ) { toast ( 'Abo beenden braucht eine Verbindung.' ) ; return ; }
feedView . confirmRemove = true ;
render ( ) ;
} ) ;
box . append ( b ) ;
return box ;
}
box . classList . add ( 'nui-pod-unsub-ask' ) ;
box . append ( el ( 'div' , 'nui-pod-title' , ` „ ${ plain ( f . title ) } “ nicht mehr abonnieren? ` ) ,
el ( 'div' , 'nui-pod-meta' , 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt und Favoriten, ' +
'auch auf dem Server.' + ( DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : '' ) ) ) ;
const actions = el ( 'div' , 'nui-pod-unsub-actions' ) ;
const yes = el ( 'button' , 'btn nui-pod-unsub-yes' , feedView . removing ? 'Wird beendet …' : 'Abo beenden' ) ;
yes . type = 'button' ;
yes . disabled = ! ! feedView . removing ;
yes . addEventListener ( 'click' , ( ) => unsubscribe ( f ) ) ;
const no = el ( 'button' , 'btn' , 'Abbrechen' ) ;
no . type = 'button' ;
no . disabled = ! ! feedView . removing ;
no . addEventListener ( 'click' , ( ) => { feedView . confirmRemove = false ; render ( ) ; } ) ;
actions . append ( yes , no ) ;
box . append ( actions ) ;
return box ;
}
async function unsubscribe ( f ) {
if ( ! navigator . onLine ) { toast ( 'Abo beenden braucht eine Verbindung.' ) ; return ; }
feedView . removing = true ;
render ( ) ;
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 ) {
if ( feedView && feedView . feed . id === f . id ) { feedView . removing = false ; render ( ) ; }
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 ) ;
}
// ---- all episodes, filtered (server: GET /podcasts/episodes/) ----------------------
const FILTERS = [
{ id : 'unplayed' , label : 'Ungehört' } ,
{ id : 'in_progress' , label : 'Angefangen' } ,
{ id : 'favorites' , label : 'Favoriten' } ,
{ id : 'history' , label : 'Verlauf' } ,
{ id : 'played' , label : 'Gehört' } ,
{ id : 'all' , label : 'Alle' } ,
] ;
let epFilter = 'unplayed' ;
try { epFilter = localStorage . getItem ( 'nui_pod_filter' ) || 'unplayed' ; } catch ( e ) { }
let epList = null ; // {filter, items, total, loading, error}
async function loadEpisodes ( filter , append ) {
const offset = append && epList && epList . filter === filter ? epList . items . length : 0 ;
if ( ! append ) epList = { filter , items : [ ] , total : 0 , loading : true } ;
else epList . loading = true ;
render ( ) ;
try {
const r = await fetch ( ` /podcasts/episodes/?filter= ${ filter } &limit=100&offset= ${ offset } ` ) ;
if ( ! r . ok ) throw new Error ( r . status ) ;
const d = await r . json ( ) ;
if ( ! epList || epList . filter !== filter ) return ;
epList = { filter , items : ( append ? epList . items : [ ] ) . concat ( d . episodes || [ ] ) , total : d . total || 0 } ;
} catch ( e ) {
if ( ! epList || epList . filter !== filter ) return ;
epList . loading = false ;
epList . error = 'Die Folgenliste braucht eine Verbindung.' ;
}
render ( ) ;
}
function renderEpisodes ( ) {
const chips = el ( 'div' , 'nui-pod-filters' ) ;
for ( const f of FILTERS ) {
const b = el ( 'button' , 'nui-pod-chip' + ( f . id === epFilter ? ' on' : '' ) , f . label ) ;
b . type = 'button' ;
b . addEventListener ( 'click' , ( ) => {
epFilter = f . id ;
try { localStorage . setItem ( 'nui_pod_filter' , epFilter ) ; } catch ( e ) { }
loadEpisodes ( epFilter , false ) ;
} ) ;
chips . append ( b ) ;
}
root . append ( chips ) ;
if ( ! epList || epList . filter !== epFilter ) { loadEpisodes ( epFilter , false ) ; empty ( 'Wird geladen …' ) ; return ; }
if ( epList . error && ! epList . items . length ) { empty ( epList . error ) ; return ; }
if ( ! epList . items . length ) {
empty ( epList . loading ? 'Wird geladen …' : {
unplayed : 'Keine ungehörten Folgen.' ,
in _progress : 'Keine angefangenen Folgen.' ,
favorites : 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.' ,
history : 'Noch nichts gehört.' ,
played : 'Noch keine gehörten Folgen.' ,
all : 'Keine Folgen.' ,
} [ epFilter ] ) ;
return ;
}
root . append ( el ( 'div' , 'nui-pod-summary' , ` ${ epList . total } ${ epList . total === 1 ? 'Folge' : 'Folgen' } ` ) ) ;
const list = el ( 'div' , 'nui-pod-list' ) ;
for ( const o of epList . items ) {
const e = ep ( o ) ;
const row = episodeRow ( e , { showFeed : true , showDate : epFilter !== 'history' , noSelect : true } ) ;
if ( epFilter === 'history' && o . last _played _at ) {
row . querySelector ( '.nui-pod-meta' ) . textContent = 'gehört ' + day ( o . last _played _at ) + ' · ' + ( e . feed || '' ) ;
}
if ( o . favorite ) row . querySelector ( '.nui-pod-title' ) . prepend ( el ( 'span' , 'nui-pod-star' , '★ ' ) ) ;
row . append (
inQueue ( e . id ) ? button ( '✓' , 'In der Warteschlange' , ( ) => toast ( 'Schon in der Warteschlange' ) , 'nui-pod-queued' )
: button ( '+' , 'Einreihen' , ( ) => enqueue ( e ) ) ,
button ( '⋯' , 'Mehr' , ( ) => episodeMenu ( e ) ) ) ;
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 ( epFilter , 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 ) {
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 ( ) ;
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 ( view === 'episodes' && epList ) {
const item = epList . items . find ( x => x . id === e . id ) ;
if ( item ) item . favorite = d . favorite ;
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 ) ) ;
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 ( ) ;
}
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 = 'Podcast suchen oder Feed-Adresse einfügen …' ;
input . setAttribute ( 'enterkeyhint' , 'search' ) ;
input . value = searchResults ? searchResults . q : '' ;
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 ) ;
if ( ! searchResults ) {
empty ( 'Sucht im Podcast-Verzeichnis von Apple. Eine Feed-Adresse (https://…) abonnierst du direkt.' ) ;
return ;
}
if ( searchResults . loading ) { empty ( 'Suche läuft …' ) ; return ; }
if ( searchResults . error ) { empty ( searchResults . error ) ; return ; }
if ( ! searchResults . items . length ) { empty ( 'Nichts gefunden.' ) ; return ; }
const subscribed = new Set ( feeds ( ) . map ( f => f . rss _url ) ) ;
const list = el ( 'div' , 'nui-pod-list' ) ;
for ( const r of searchResults . items ) {
const row = el ( 'div' , 'nui-pod-row' ) ;
row . append ( art ( r . artwork _url ) ) ;
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 ) ;
}
async function search ( q ) {
if ( ! q ) return ;
if ( /^https?:\/\//i . test ( q ) ) { subscribe ( q , q ) ; return ; }
searchResults = { q , loading : true } ;
render ( ) ;
try {
const r = await fetch ( '/podcasts/search/?q=' + encodeURIComponent ( q ) ) ;
const data = await r . json ( ) ;
searchResults = { q , items : data . results || [ ] , error : data . error ? 'Die Suche ist gerade nicht erreichbar.' : null } ;
} catch ( e ) {
searchResults = { q , items : [ ] , error : 'Die Suche braucht eine Verbindung.' } ;
}
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 ) ;
} catch ( e ) {
toast ( navigator . onLine ? 'Dieser Feed lässt sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.' ) ;
}
}
// ---- 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 = '<div class="nui-pod-mini-bar"><span></span></div>' ;
const text = el ( 'button' , 'nui-mini-text' ) ;
text . type = 'button' ;
text . append ( el ( 'span' , 'nui-mini-station' ) , el ( 'span' , 'nui-mini-track' ) ) ;
2026-10-02 12:44:49 +02:00
text . addEventListener ( 'click' , ( ) => ( miniKind === 'radio' ? openRadioPlayer ( ) : openPlayer ( ) ) ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
mini . append ( art ( '' , 'mini' ) , text ,
button ( '↺' , '15 s zurück' , ( ) => N . podcastSkip ( false ) , 'nui-pod-skip' ) ,
2026-10-02 12:44:49 +02:00
button ( '☆' , 'Sender speichern' , ( ) => window . _ _dioraRadioSave && window . _ _dioraRadioSave ( ) , 'nui-mini-save' ) ,
button ( '▶' , 'Abspielen' , ( ) => ( miniKind === 'radio'
? window . _ _dioraRadioToggle && window . _ _dioraRadioToggle ( ) : N . podcastToggle ( ) ) , 'nui-mini-toggle' ) ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
dock . prepend ( mini ) ;
2026-10-02 12:44:49 +02:00
// One player for podcasts and radio: the radio's own bar steps aside.
document . documentElement . classList . add ( 'nui-one-player' ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// 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"]' ) ) ;
}
}
2026-10-02 12:44:49 +02:00
// What the bar shows: 'podcast', 'radio' (a station is chosen) 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 ( ) ;
}
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
function renderMini ( ) {
const isPod = player . kind === 'podcast' ;
document . documentElement . classList . toggle ( 'nui-pod-playing' , isPod ) ;
if ( ! mini ) return ;
2026-10-02 12:44:49 +02:00
const radio = isPod ? null : radioNow ( ) ;
miniKind = isPod ? 'podcast' : radio ? 'radio' : '' ;
mini . hidden = ! miniKind ;
mini . classList . toggle ( 'nui-mini-radio' , miniKind === 'radio' ) ;
document . documentElement . classList . toggle ( 'nui-pod-mini-open' , ! ! miniKind ) ;
mini . querySelector ( '.nui-pod-skip' ) . hidden = miniKind !== 'podcast' ;
mini . querySelector ( '.nui-mini-save' ) . hidden = miniKind !== 'radio' || radio . saved ;
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 ;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ( ( player . dur - player . pos ) / 1000 ) + ' übrig' : '' ) ;
const t = mini . querySelector ( '.nui-mini-toggle' ) ;
t . textContent = player . playing ? '❚❚' : '▶' ;
t . title = player . playing ? 'Pause' : 'Abspielen' ;
2026-10-02 12:44:49 +02:00
setArt ( mini . querySelector ( '.nui-pod-art' ) , player . artwork ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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/<id>/) ------------------------
// 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 || [ ] ;
if ( live && chapters . length > 1 ) {
// Previous / next chapter, like AntennaPod's chapter buttons.
const nav = el ( 'div' , 'nui-pod-chapnav' ) ;
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 ) ) ;
} ;
nav . append ( button ( '⏮' , 'Voriges Kapitel' , ( ) => jump ( - 1 ) ) , el ( 'span' , 'nui-pod-chapnow' ) , button ( '⏭' , 'Nächstes Kapitel' , ( ) => jump ( 1 ) ) ) ;
box . append ( nav ) ;
}
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 || '<p>Keine Shownotes.</p>' ;
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 ) tab ( 'chapters' , ` Kapitel ( ${ chapters . length } ) ` ) ;
tab ( 'notes' , 'Shownotes' ) ;
if ( d . has _transcript ) tab ( 'transcript' , 'Transkript' ) ;
box . append ( tabs , body ) ;
show ( chapters . length ? 'chapters' : 'notes' ) ;
}
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 now = scope . querySelector ( '.nui-pod-chapnow' ) ;
if ( now ) {
const d = storedDetails ( parseInt ( ( scope . querySelector ( '.nui-pod-notesbox' ) || scope ) . dataset . episode , 10 ) ) ;
const ch = d && d . chapters ? d . chapters . filter ( c => c . start <= seconds + 0.5 ) . pop ( ) : null ;
now . textContent = ch ? plain ( ch . title || '' ) : '' ;
}
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 ;
}
2026-10-02 12:44:49 +02:00
// The full player: one for both. Which one it shows now.
let sheetKind = '' ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
function openPlayer ( ) {
if ( player . kind !== 'podcast' ) return ;
closePlayer ( ) ;
2026-10-02 12:44:49 +02:00
sheetKind = 'podcast' ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
sheet = el ( 'div' ) ;
sheet . id = 'nui-pod-player' ;
sheet . innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>' ;
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' ) ) ;
panel . append ( el ( 'div' , 'nui-pod-player-title' ) , el ( 'div' , 'nui-pod-player-feed' ) ) ;
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 ( range . value ) ;
} ) ;
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 ( '⏭' , 'Folge abschließen, nächste spielen' , finishAndNext , 'nui-pod-next' ) ,
button ( '≡' , 'Warteschlange' , ( ) => { closePlayer ( ) ; view = 'queue' ; feedView = null ; showTab ( 'podcasts' ) ; render ( ) ; } ) ,
) ;
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 ;
}
2026-10-02 12:44:49 +02:00
// 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 = '<div class="nui-pod-player-panel" role="dialog" aria-label="Radio"></div>' ;
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 ) ;
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' ? openRadioPlayer ( ) : openPlayer ( ) ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
function renderPlayer ( ) {
if ( ! sheet ) return ;
2026-10-02 12:44:49 +02:00
if ( sheetKind === 'radio' ) {
if ( player . kind === 'podcast' ) closePlayer ( ) ; else renderRadioPlayer ( ) ;
return ;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ;
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 ) : '' ;
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.
function speedMenu ( ) {
const own = ! ! player . podcastSpeed ;
const pick = forPodcast => openSheet ( forPodcast ? ` Geschwindigkeit für „ ${ plain ( player . feed ) } “ ` : 'Geschwindigkeit für alle Podcasts' ,
SPEEDS . 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 ) } ] : [ ] ) ) ;
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 ) } ,
] ) ;
}
// ---- 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.
2026-10-02 12:44:49 +02:00
function sleepMenu ( radio ) {
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ) } ) ;
}
2026-10-02 12:44:49 +02:00
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 ) } ) ;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ) ;
}
function finishAndNext ( ) {
const id = currentId ( ) ;
if ( ! id ) return ;
N . podcastNext ( ) ;
N . podcastMarkPlayed ( id , true ) ;
}
// ---- 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 ( ) , { } ) : { } ;
2026-10-02 12:44:49 +02:00
// 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 ( ) , { } ) : { } ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
const DL _COUNTS = [ 1 , 2 , 3 , 5 , 10 ] ;
2026-10-02 12:44:49 +02:00
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' ,
} ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// 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 = [
2026-10-02 11:46:37 +02:00
{ value : 'queue' , label : 'Wenn Platz gebraucht wird' ,
hint : 'beim Limit gehen Folgen, die nicht in der Warteschlange stehen: gehörte zuerst' } ,
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
{ 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 ) ) ;
2026-10-02 12:44:49 +02:00
window . _ _dioraDownloads ( Object . assign ( { } , downloads ,
{ [ e . id ] : { state : 'queued' , progress : 0 , bytes : 0 , total : 0 , title : e . title , reason : 'queue' } } ) ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
toast ( 'Wird heruntergeladen' ) ;
}
function dlDelete ( e , running ) {
N . podcastDeleteDownload ( e . id ) ;
toast ( running ? 'Download abgebrochen' : 'Download gelöscht' ) ;
}
function dlAppend ( row , e ) {
if ( ! DL || ! ( e . raw . episode _ _audio _url || e . raw . audio _url ) ) return ;
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 ) ;
row . append ( b ) ;
}
function dlRing ( b ) {
const d = downloads [ b . dataset . dl ] ;
if ( ! d || ( d . state !== 'queued' && d . state !== 'downloading' ) ) return ;
b . style . setProperty ( '--p' , ( d . progress || 0 ) + '%' ) ;
2026-10-02 12:44:49 +02:00
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' ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
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 ) } ] ;
2026-10-02 12:44:49 +02:00
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 ) } ,
] ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
}
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 ? ` die ersten ${ s . count } Folgen der Warteschlange ` : 'aus' , action : ( ) => dlSet ( { auto : ! s . auto } ) } ,
s . auto && { icon : '#' , label : 'Wie viele Folgen' , hint : String ( s . count ) , action : ( ) => openSheet ( 'Automatisch herunterladen' ,
DL _COUNTS . map ( n => ( { icon : s . count === n ? '●' : '○' , label : n === 1 ? '1 Folge' : n + ' Folgen' , 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 } ) } ,
2026-10-02 12:44:49 +02:00
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 } ) } ) ) ) } ,
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
{ 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 } ) } ) ) ) } ,
2026-10-02 11:46:37 +02:00
'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 } ) } ,
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ) } ) ` , danger : true ,
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 : ( ) => { } } ,
] ) } ,
] ;
}
2026-10-02 12:44:49 +02:00
window . _ _dioraDownloads = function ( map , info ) {
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
const before = downloads ;
downloads = ( typeof map === 'string' ? parse ( map , downloads ) : map ) || { } ;
2026-10-02 12:44:49 +02:00
if ( info ) dlInfo = typeof info === 'string' ? parse ( info , dlInfo ) : info ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
const keys = Object . keys ( downloads ) ;
const same = keys . length === Object . keys ( before ) . length &&
2026-10-02 12:44:49 +02:00
keys . every ( id => before [ id ] && before [ id ] . state === downloads [ id ] . state && before [ id ] . reason === downloads [ id ] . reason ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going).
2026-10-02 12:44:49 +02:00
if ( same ) {
document . querySelectorAll ( '#nui-pod [data-dl]' ) . forEach ( dlRing ) ;
dlIndicatorText ( ) ;
} else {
render ( ) ;
}
renderDownloads ( ) ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
} ;
2026-10-02 12:44:49 +02:00
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 ) ;
}
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' , danger : true , action : ( ) => N . podcastDeleteDownload ( d . id ) }
: { icon : '✕' , label : 'Abbrechen' , hint : d . bytes ? 'was schon geladen ist, geht mit' : null , danger : true ,
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' ) ) ;
dlBody . 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 ) ) } ` ) ) ;
}
if ( ! all . length ) dlBody . append ( el ( 'div' , 'nui-pod-empty' , 'Keine Downloads. Beim ↓ einer Folge lädst du sie aufs Telefon.' ) ) ;
panel . scrollTop = scroll ;
}
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
// ---- hooks from the app ---------------------------------------------------------------
window . _ _dioraPodcasts = function ( json ) {
state = typeof json === 'string' ? parse ( json , state ) : json ;
render ( ) ;
} ;
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 ( ) ;
} ;
// The app loads this in <head>; 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 } ) ;
render ( ) ;
renderMini ( ) ;
const back = window . _ _dioraBack ;
window . _ _dioraBack = function ( ) {
if ( closeStats ( ) ) return true ;
2026-10-02 12:44:49 +02:00
if ( closeDownloads ( ) ) return true ;
Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die
Android-App: native-podcast.js aus diora-android, unverändert kopiert nach
static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf),
dazu die Podcast-Teile von native.css.
static/js/podcasts-web.js spielt im Browser die Rolle der App
(window.DioraPodcastHost statt DioraNative):
- Zustand aus /podcasts/state/, in localStorage für einen schnellen Start;
Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht
Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“
- eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist,
wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt
alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“),
automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und
Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit
Ausblenden
- Sperrbildschirm und Medientasten über navigator.mediaSession
- Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs)
- keine Downloads (die Oberfläche blendet sie aus)
- Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender
legt die Folge weg
- Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten
Bildschirmen eine mittige Spalte (760 px)
In der App und der Desktop-App (window.DioraNative vorhanden) tut
podcasts-web.js nichts.
Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die
Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von
Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab).
Service-Worker-Cache auf v52, neue Dateien im App-Shell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 +02:00
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 ( feedView ) { feedView = null ; render ( ) ; return true ; }
showTab ( 'books' ) ;
return true ;
}
return false ;
} ;
// Pull the latest state once per start; the server has done the fetching.
N . podcastRefresh ( false ) ;
} ) ;
} ) ( ) ;