Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
// Copied from diora-android 38d03c6 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 ) {
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
const r = remote ( ) ;
if ( r ? r . kind === 'podcast' && r . episodeId === e . id : currentId ( ) === e . id ) { openPlayer ( ) ; 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
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 ( ) ) ) ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
const devices = button ( '⇄' , 'Wiedergabe auf …' , deviceMenu , 'nui-mini-devices' ) ;
const here = button ( '⇩' , 'Hier abspielen' , takeOver , 'nui-mini-here' ) ;
devices . hidden = here . hidden = 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
mini . append ( art ( '' , 'mini' ) , text ,
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
button ( '↺' , '15 s zurück' , ( ) => ( miniKind === 'remote'
? remoteCmd ( 'skip' , - ( devicePrefs ( ) . skip _back || 15 ) ) : N . podcastSkip ( false ) ) , 'nui-pod-skip' ) ,
2026-10-02 12:44:49 +02:00
button ( '☆' , 'Sender speichern' , ( ) => window . _ _dioraRadioSave && window . _ _dioraRadioSave ( ) , 'nui-mini-save' ) ,
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
devices , here ,
button ( '▶' , 'Abspielen' , ( ) => ( miniKind === 'remote' ? remoteToggle ( )
: 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"]' ) ) ;
}
}
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
// (another device is the active one) or ''.
2026-10-02 12:44:49 +02:00
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 ( ) {
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
const r = remote ( ) ;
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 isPod = player . kind === 'podcast' ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
document . documentElement . classList . toggle ( 'nui-pod-playing' , isPod || ! ! r ) ;
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 ( ! mini ) return ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
const radio = isPod || r ? null : radioNow ( ) ;
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '' ;
2026-10-02 12:44:49 +02:00
mini . hidden = ! miniKind ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
mini . classList . toggle ( 'nui-mini-radio' , miniKind === 'radio' || ( ! ! r && r . kind === 'radio' ) ) ;
mini . classList . toggle ( 'nui-mini-remote' , ! ! r ) ;
2026-10-02 12:44:49 +02:00
document . documentElement . classList . toggle ( 'nui-pod-mini-open' , ! ! miniKind ) ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
mini . querySelector ( '.nui-pod-skip' ) . hidden = ! ( isPod || r ) || ( ! ! r && r . kind !== 'podcast' ) ;
2026-10-02 12:44:49 +02:00
mini . querySelector ( '.nui-mini-save' ) . hidden = miniKind !== 'radio' || radio . saved ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
mini . querySelector ( '.nui-mini-devices' ) . hidden = ! r || ! N . playerTransfer ;
mini . querySelector ( '.nui-mini-here' ) . hidden = ! r || ! N . playerTakeOver ;
if ( r ) {
const onRadio = r . kind === 'radio' ;
mini . querySelector ( '.nui-mini-station' ) . textContent = ( onRadio ? r . station && r . station . name : r . title ) || '' ;
mini . querySelector ( '.nui-mini-track' ) . textContent = remoteLine ( r ) ;
const t = mini . querySelector ( '.nui-mini-toggle' ) ;
t . textContent = r . playing ? ( onRadio ? '■' : '❚❚' ) : '▶' ;
t . title = r . playing ? ( onRadio ? 'Stopp' : 'Pause' ) + ' auf ' + remoteName ( r ) : 'Abspielen auf ' + remoteName ( r ) ;
setArt ( mini . querySelector ( '.nui-pod-art' ) , onRadio ? r . station && r . station . logo : r . artwork ) ;
mini . querySelector ( '.nui-pod-mini-bar span' ) . style . width = onRadio ? ( r . playing ? '100%' : '0' )
: r . dur ? Math . min ( 100 , r . pos / r . dur * 100 ) + '%' : '0' ;
return ;
}
2026-10-02 12:44:49 +02:00
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 ( ) {
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
if ( remote ( ) ) { openRemotePlayer ( ) ; 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' ) 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' ) ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
extra . append ( ... [
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
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' ) ,
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
N . playerTransfer && button ( '⇄' , 'Wiedergabe auf …' , deviceMenu , 'nui-pod-devices' ) ,
button ( '≡' , 'Warteschlange' , showQueue ) ,
] . 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
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 ) ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
if ( N . playerTransfer ) extra . append ( devicesButton ( ) ) ;
2026-10-02 12:44:49 +02:00
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 ( ) ;
} ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
window . _ _dioraOpenPlayer = kind => ( kind === 'radio' && ! remote ( ) ? openRadioPlayer ( ) : openPlayer ( ) ) ;
2026-10-02 12:44:49 +02:00
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 ;
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
const r = remote ( ) ;
if ( sheetKind === 'remote' ) {
// Back here (taken over) or the other device went away: this device's player.
if ( ! r ) {
closePlayer ( ) ;
if ( player . kind === 'podcast' ) openPlayer ( ) ; else if ( radioNow ( ) ) openRadioPlayer ( ) ;
} else if ( sheet . dataset . remote !== remoteKey ( r ) ) {
openRemotePlayer ( ) ;
} else {
renderRemotePlayer ( ) ;
}
return ;
}
if ( r ) { openRemotePlayer ( ) ; return ; }
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 ) ;
}
Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.
Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00
function showQueue ( ) {
closePlayer ( ) ;
view = 'queue' ;
feedView = null ;
showTab ( 'podcasts' ) ;
render ( ) ;
}
// ---- another device plays (player sync, like Spotify Connect) ------------------------------
// The host (Bridge.kt / podcasts-web.js) shares one player among the user's
// devices. While another device is the active one (online, playing or
// paused), player.remote is what it plays: the bar and the player show it,
// their buttons control it (N.playerRemote), "Hier abspielen" plays it here
// from its place, and the device sheet moves it to any device that is online.
function remote ( ) {
return player . remote && N . playerRemote ? player . remote : null ;
}
function remoteName ( r ) {
return r . device || 'einem anderen Gerät' ;
}
function remoteKey ( r ) {
return r . kind + ':' + ( r . kind === 'radio' ? ( r . station && r . station . url ) || '' : r . episodeId ) ;
}
function remoteLine ( r ) {
return ( r . playing ? '▸ läuft auf ' : '‖ pausiert auf ' ) + remoteName ( r ) ;
}
function remoteCmd ( action , value ) {
if ( N . playerRemote ) N . playerRemote ( action , Math . round ( value || 0 ) ) ;
}
function remoteToggle ( ) {
const r = remote ( ) ;
if ( r ) remoteCmd ( r . playing ? 'pause' : 'play' ) ;
}
function takeOver ( ) {
if ( N . playerTakeOver ) N . playerTakeOver ( ) ;
}
// Which device plays: the ones online, this one first; choosing one moves the playback there.
function deviceMenu ( ) {
if ( ! N . playerTransfer ) return ;
const all = ( player . devices || [ ] ) . filter ( d => d . online || d . me ) ;
const devices = all . filter ( d => d . me ) . concat ( all . filter ( d => ! d . me ) ) ;
const r = remote ( ) ;
const radio = r ? null : radioNow ( ) ;
const activePlays = r ? r . playing : player . kind === 'podcast' ? ! ! player . playing : ! ! ( radio && radio . playing ) ;
const items = devices . map ( d => ( {
icon : d . active ? '◉' : '○' ,
label : d . me ? 'Dieses Gerät' : d . name || 'Unbekanntes Gerät' ,
hint : d . active ? ( activePlays ? 'spielt gerade' : 'pausiert' ) : d . me ? null : 'hierhin verschieben' ,
active : ! ! d . active ,
action : ( ) => N . playerTransfer ( d . id ) ,
} ) ) ;
if ( devices . length < 2 ) {
items . push ( { custom : el ( 'div' , 'nui-pod-meta nui-pod-devnote' ,
'Keine anderen Geräte online. Sobald diora auf einem anderen Gerät offen ist, erscheint es hier.' ) } ) ;
}
openSheet ( 'Wiedergabe auf …' , items ) ;
}
function devicesButton ( ) {
const b = el ( 'button' , 'btn nui-pod-devbtn' , '⇄ Wiedergabe auf …' ) ;
b . type = 'button' ;
b . addEventListener ( 'click' , deviceMenu ) ;
return b ;
}
// The full player for what another device plays: the same layout, remote buttons.
function openRemotePlayer ( ) {
const r = remote ( ) ;
if ( ! r ) return ;
closePlayer ( ) ;
sheetKind = 'remote' ;
const onRadio = r . kind === 'radio' ;
sheet = el ( 'div' ) ;
sheet . id = 'nui-pod-player' ;
sheet . className = 'nui-pod-remote' ;
sheet . dataset . remote = remoteKey ( r ) ;
sheet . innerHTML = '<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 ) ) ;
const cover = art ( onRadio ? r . station && r . station . logo : r . artwork , 'player' ) ;
if ( onRadio ) cover . classList . add ( 'nui-radio-art' ) ;
panel . append ( top , cover , el ( 'div' , 'nui-pod-player-title' ) , el ( 'div' , 'nui-pod-player-feed nui-pod-remote-line' ) ) ;
const controls = el ( 'div' , 'nui-pod-controls' ) ;
if ( onRadio ) {
controls . append ( button ( '▶' , 'Abspielen' , remoteToggle , 'nui-pod-play' ) ) ;
} else {
panel . append ( el ( 'div' , 'nui-pod-player-feed nui-pod-remote-feed' ) ) ;
const range = el ( 'input' , 'nui-pod-seek' ) ;
range . type = 'range' ;
range . min = 0 ;
range . step = 1 ;
range . addEventListener ( 'input' , ( ) => {
seeking = true ;
sheet . querySelector ( '.nui-pod-time-pos' ) . textContent = clock ( range . value ) ;
} ) ;
range . addEventListener ( 'change' , ( ) => {
remoteCmd ( 'seek' , range . value * 1000 ) ;
seeking = false ;
} ) ;
const times = el ( 'div' , 'nui-pod-times' ) ;
times . append ( el ( 'span' , 'nui-pod-time-pos' ) , el ( 'span' , 'nui-pod-time-left' ) ) ;
panel . append ( range , times ) ;
const prefs = devicePrefs ( ) ;
const back = prefs . skip _back || 15 ;
const fwd = prefs . skip _forward || 30 ;
controls . append (
button ( String ( back ) , ` ${ back } Sekunden zurück ` , ( ) => remoteCmd ( 'skip' , - back ) , 'nui-pod-back' ) ,
button ( '▶' , 'Abspielen' , remoteToggle , 'nui-pod-play' ) ,
button ( String ( fwd ) , ` ${ fwd } Sekunden vor ` , ( ) => remoteCmd ( 'skip' , fwd ) , 'nui-pod-fwd' ) ,
) ;
}
panel . append ( controls ) ;
if ( N . playerTakeOver ) {
const here = el ( 'button' , 'btn nui-pod-here' , '⇩ Hier abspielen' ) ;
here . type = 'button' ;
here . addEventListener ( 'click' , takeOver ) ;
panel . append ( here ) ;
}
const extra = el ( 'div' , 'nui-pod-extra' ) ;
if ( ! onRadio ) extra . append ( button ( '⏭' , 'Nächste Folge' , ( ) => remoteCmd ( 'next' ) , 'nui-pod-next' ) ) ;
if ( N . playerTransfer ) extra . append ( onRadio ? devicesButton ( ) : button ( '⇄' , 'Wiedergabe auf …' , deviceMenu , 'nui-pod-devices' ) ) ;
if ( ! onRadio ) extra . append ( button ( '≡' , 'Warteschlange' , showQueue ) ) ;
panel . append ( extra ) ;
if ( ! onRadio && r . episodeId ) {
const notes = el ( 'div' , 'nui-pod-notesbox' ) ;
panel . append ( notes ) ;
showDetails ( notes , r . episodeId , false ) ;
}
sheet . addEventListener ( 'click' , e => { if ( e . target === sheet ) closePlayer ( ) ; } ) ;
document . body . append ( sheet ) ;
renderRemotePlayer ( ) ;
}
function renderRemotePlayer ( ) {
const r = remote ( ) ;
if ( ! r || ! sheet ) return ;
const onRadio = r . kind === 'radio' ;
sheet . querySelector ( '.nui-pod-player-title' ) . textContent = ( onRadio ? r . station && r . station . name : r . title ) || '' ;
sheet . querySelector ( '.nui-pod-remote-line' ) . textContent = remoteLine ( r ) ;
const p = sheet . querySelector ( '.nui-pod-play' ) ;
p . textContent = r . playing ? ( onRadio ? '■' : '❚❚' ) : '▶' ;
p . title = ( r . playing ? ( onRadio ? 'Stopp' : 'Pause' ) : 'Abspielen' ) + ' auf ' + remoteName ( r ) ;
if ( onRadio ) return ;
sheet . querySelector ( '.nui-pod-remote-feed' ) . textContent = r . feed || '' ;
const range = sheet . querySelector ( '.nui-pod-seek' ) ;
const dur = Math . round ( ( r . dur || 0 ) / 1000 ) ;
const pos = Math . round ( ( r . pos || 0 ) / 1000 ) ;
range . max = dur || 1 ;
if ( ! seeking ) {
range . value = pos ;
sheet . querySelector ( '.nui-pod-time-pos' ) . textContent = clock ( pos ) ;
}
sheet . querySelector ( '.nui-pod-time-left' ) . textContent = dur ? '− ' + clock ( dur - pos ) : '' ;
markChapter ( sheet , pos ) ;
}
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
// ---- 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 ) ;
} ) ;
} ) ( ) ;