Podcast-Radio: die neueste ungehörte Strom-Folge, ohne Spuren (eigene Radio-gehört-Liste, gemischt nach Podcasts, neue Folgen zuerst); Web-Player und Player-Sync; sw v153
Some checks failed
Build and push Docker image / build (push) Failing after 0s
Test / test (push) Failing after 0s
Test / e2e (push) Failing after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-25 13:17:09 +01:00
parent 21f5e33e57
commit a64c801275
11 changed files with 497 additions and 25 deletions

View file

@ -92,7 +92,7 @@ One controlled step out (Apple's episode search and fyyd; no key):
Show search stays on Apple (best coverage). Show search stays on Apple (best coverage).
## Podcast-Radio (planned 2026-10-25) ## Podcast-Radio (since 2026-10-25)
Listening without committing: one button plays the Strom like a station. Listening without committing: one button plays the Strom like a station.
@ -100,6 +100,11 @@ Listening without committing: one button plays the Strom like a station.
neither played, nor started (normally), nor done for the radio. So an episode that comes out neither played, nor started (normally), nor done for the radio. So an episode that comes out
while one plays is next (the one playing isn't interrupted); otherwise it goes back in time, while one plays is next (the one playing isn't interrupted); otherwise it goes back in time,
without limit. Auf Abruf podcasts and audiobooks aren't in the Strom, so not in the radio. without limit. Auf Abruf podcasts and audiobooks aren't in the Strom, so not in the radio.
- **Mixed**: several episodes of one podcast in a row are mixed with the others waiting — the
next is the newest of *another* podcast than the one before, if there is one (within the
newest 200).
- What comes next is asked anew at every end and skip (so the newest really comes next); the
list fetched before is for "Danach: …" in the player and for going on offline.
- **Leaves no trace**: no progress (no Verlauf, no "Angefangen", no played mark, not counted in - **Leaves no trace**: no progress (no Verlauf, no "Angefangen", no played mark, not counted in
`heard_365`), the Wiedergabeliste and Merkliste unchanged, nothing downloaded (it streams; a `heard_365`), the Wiedergabeliste and Merkliste unchanged, nothing downloaded (it streams; a
downloaded file plays if there is one), the Strom's marks unchanged. downloaded file plays if there is one), the Strom's marks unchanged.
@ -108,14 +113,23 @@ Listening without committing: one button plays the Strom like a station.
- **Committing**: in the player while the radio plays, + (Merkliste) and "Normal weiterhören" - **Committing**: in the player while the radio plays, + (Merkliste) and "Normal weiterhören"
(first in the Wiedergabeliste at the current place; from then on it plays as usual, with (first in the Wiedergabeliste at the current place; from then on it plays as usual, with
progress). Playing anything normally switches the radio off (as an audiobook does). progress). Playing anything normally switches the radio off (as an audiobook does).
- **Server**: `PodcastRadioHeard(user, episode, at)`, kept apart from progress, pruned after 60 - **Server** (`podcasts/radio.py`): `PodcastRadioHeard(user, episode, at)`, kept apart from
days. `GET /podcasts/radio/?limit=5` → the next episodes by the rule (`{episodes: [...]}`, the progress and kept for good (pruned, an old episode would come back when the radio goes back
rows of the episode lists); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. that far). `GET /podcasts/radio/?after=<playing>&skip=<ids>&limit=5` → the next episodes by
- **Clients** (both at once): Android (`PodcastRadio.kt` beside `BookPlay.kt`: the playlist is the rule (`{episodes: [...]}`, the rows of the episode lists; `after` isn't among them and its
[current, next]; RadioService's `save()` / `markPlayed()` / `followQueue()` leave radio items podcast not first; `skip`: ones the device is done with whose /heard/ may still be on its
alone; reloads before each transition and every 10 minutes while playing — the server fetches way); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. Filter `untouched`
feeds every 15 minutes) and the web player (`podcasts-web.js`, so the browser and the desktop (episode lists): neither played nor listened into.
app). Button "📻 Radio" at the top of the Strom; the mini player's ▶ goes on with the radio. - **Player sync**: a report carries `podcast_radio: true` while the radio plays
(`PlayerState.podcast_radio`); taking it over ("Hier abspielen", a transfer) or adopting it goes
on with the radio on that device.
- **Clients**: the web player (`podcasts-web.js`: an entry with `radio: true`, the radio's own
`podradio` in localStorage — next ones, its place, what it was done with lately; host
`podcastRadio()`, `podcastRadioCommit()`, `podcastRadioLater()`; `playerState().radio =
{next}`) and Android (`PodcastRadio.kt` beside `BookPlay.kt`; RadioService's `save()` /
`markPlayed()` / `followQueue()` leave radio items alone). UI (native-podcast.js): 📻 at the top
of the Strom, "Podcast-Radio · <podcast>" in the mini player and the player, there a box with
"Danach: …", + Merkliste and "Normal weiterhören"; ⏭ only skips.
- Later perhaps: a radio of one folder, a longest length. - Later perhaps: a radio of one folder, a longest length.
## Moving over ## Moving over

View file

@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-10-25 12:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0024_feed_favorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='playerstate',
name='podcast_radio',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='PodcastRadioHeard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('at', models.DateTimeField(auto_now_add=True)),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='radio_heard_by', to='podcasts.podcastepisode')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_radio_heard', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'episode')},
},
),
]

View file

@ -290,6 +290,21 @@ class PodcastLater(models.Model):
return f'{self.user} Merkliste: {self.episode}' return f'{self.user} Merkliste: {self.episode}'
class PodcastRadioHeard(models.Model):
"""An episode the Podcast-Radio is done with (docs/podcasts.md): it ran to its end there or
was skipped. Apart from the progress on purpose: it counts as heard nowhere else (no
history, no played mark, not in heard_365). Kept, so the radio never comes back to it."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_radio_heard')
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='radio_heard_by')
at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'episode')
def __str__(self):
return f'{self.user} Radio: {self.episode}'
class PodcastSettings(models.Model): class PodcastSettings(models.Model):
"""A user's podcast preferences. Where episodes go in the queue: when added """A user's podcast preferences. Where episodes go in the queue: when added
by hand (also when played from outside the queue), and when a feed that by hand (also when played from outside the queue), and when a feed that
@ -401,6 +416,9 @@ class PlayerState(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state') user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state')
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True) kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
episode = models.ForeignKey(PodcastEpisode, null=True, blank=True, on_delete=models.SET_NULL, related_name='+') episode = models.ForeignKey(PodcastEpisode, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
# The episode plays in the Podcast-Radio (docs/podcasts.md): shown so elsewhere, and a
# transfer goes on with the radio there rather than playing it the usual way.
podcast_radio = models.BooleanField(default=False)
station_url = models.CharField(max_length=1000, blank=True) station_url = models.CharField(max_length=1000, blank=True)
station_name = models.CharField(max_length=300, blank=True) station_name = models.CharField(max_length=300, blank=True)
station_id = models.IntegerField(null=True, blank=True) station_id = models.IntegerField(null=True, blank=True)

88
podcasts/radio.py Normal file
View file

@ -0,0 +1,88 @@
"""The Podcast-Radio (docs/podcasts.md): the Strom played like a station, leaving no trace.
Which episode comes next: the newest (publication date) Strom episode that is neither played,
nor listened into, nor done for the radio (PodcastRadioHeard) — so one that comes out while
another plays is next, and otherwise the radio goes back in time. Several of one podcast in a
row are mixed with the others waiting: the next is the newest of another podcast than the one
before, if there is one.
GET /podcasts/radio/?after=<episode id>&skip=<ids>&limit=5 → {episodes: [...]} (episode-list rows)
skip: ones the device is done with whose /heard/ may not have arrived yet
POST /podcasts/radio/heard/ {"episode_id", "after"?} → {ok, episodes} (the next ones)
"""
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import PodcastEpisode, PodcastRadioHeard
from .views import _episode_list_data
LIMIT = 5
MAX_LIMIT = 20
MAX_SKIP = 50
# How far down the newest the mixing looks for another podcast.
WINDOW = 200
def next_episodes(user, after=None, limit=LIMIT, skip=()):
"""The radio's next episodes, in the order to play; `after`: the episode playing (or
played last) — not itself again, and not its podcast next if another is waiting; `skip`:
not these either."""
candidates, _ = _episode_list_data(user, ['stream', 'untouched'], sort='new', limit=WINDOW, radio=True)
skip = set(skip) | ({after} if after else set())
candidates = [e for e in candidates if e['id'] not in skip]
last_feed = None
if after:
last_feed = PodcastEpisode.objects.filter(pk=after, feed__user=user).values_list('feed_id', flat=True).first()
out = []
while candidates and len(out) < limit:
pick = next((e for e in candidates if e['feed__id'] != last_feed), candidates[0])
candidates.remove(pick)
out.append(pick)
last_feed = pick['feed__id']
return out
def _after(value):
if value in (None, ''):
return None
after = int(value)
if after <= 0:
raise ValueError
return after
@csrf_exempt
@require_http_methods(['GET'])
def radio(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
after = _after(request.GET.get('after'))
limit = min(max(int(request.GET.get('limit') or LIMIT), 1), MAX_LIMIT)
skip = [int(i) for i in request.GET.get('skip', '').split(',') if i.strip()][:MAX_SKIP]
except ValueError:
return JsonResponse({'error': 'after, skip and limit must be numbers'}, status=400)
return JsonResponse({'episodes': next_episodes(request.user, after, limit, skip)})
@csrf_exempt
@require_http_methods(['POST'])
def radio_heard(request):
"""The radio is done with an episode (ran to its end, or skipped): it never plays it again.
Nothing else about the episode changes. Answers with the next ones (after it)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
episode_id = int(body['episode_id'])
after = _after(body.get('after'))
except (ValueError, KeyError, TypeError):
return JsonResponse({'error': 'episode_id required'}, status=400)
episode = PodcastEpisode.objects.filter(pk=episode_id, feed__user=request.user).first()
if episode is None:
return JsonResponse({'error': 'unknown episode'}, status=404)
PodcastRadioHeard.objects.get_or_create(user=request.user, episode=episode)
return JsonResponse({'ok': True, 'episodes': next_episodes(request.user, after or episode_id)})

107
podcasts/test_radio.py Normal file
View file

@ -0,0 +1,107 @@
"""The Podcast-Radio (docs/podcasts.md, podcasts/radio.py)."""
import json
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import TestCase
from .models import EpisodeProgress, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastRadioHeard
from .tests import _at, _progress
class RadioTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
self.fc = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Abruf',
new_episodes=PodcastFeed.NEW_IGNORE)
self.book = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/h', title='Buch',
kind=PodcastFeed.AUDIOBOOK)
def ep(self, feed, guid, day):
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, pub_date=_at(1, day),
audio_url=f'https://example.org/{guid}.mp3')
def radio(self, **params):
r = self.client.get('/podcasts/radio/', params)
self.assertEqual(r.status_code, 200)
return [e['title'] for e in r.json()['episodes']]
def heard(self, episode, **body):
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': episode.id, **body}),
content_type='application/json')
self.assertEqual(r.status_code, 200)
return [e['title'] for e in r.json()['episodes']]
def test_newest_untouched_strom_episodes_first(self):
self.ep(self.fa, 'a1', 1)
self.ep(self.fb, 'b2', 2)
played = self.ep(self.fa, 'a3', 3)
started = self.ep(self.fb, 'b4', 4)
self.ep(self.fc, 'c5', 5) # Auf Abruf: not in the Strom
self.ep(self.book, 'h6', 6) # an audiobook's chapter
_progress(self.user, played, _at(2, 1), played=True)
_progress(self.user, started, _at(2, 1), position_seconds=30)
self.assertEqual(self.radio(), ['b2', 'a1'])
def test_one_podcast_is_mixed_with_the_others(self):
for d in (1, 2, 3):
self.ep(self.fb, f'b{d}', d)
for d in (7, 8, 9):
self.ep(self.fa, f'a{d}', d)
self.assertEqual(self.radio(limit=6), ['a9', 'b3', 'a8', 'b2', 'a7', 'b1'])
# After an episode of Alpha: Beta first, and that episode not again.
a9 = PodcastEpisode.objects.get(guid='a9')
self.assertEqual(self.radio(after=a9.id, limit=3), ['b3', 'a8', 'b2'])
# One the device is done with (its /heard/ still on the way) doesn't count for the mixing.
a8 = PodcastEpisode.objects.get(guid='a8')
self.assertEqual(self.radio(after=a9.id, skip=str(a8.id), limit=3), ['b3', 'a7', 'b2'])
# Only one podcast left: its episodes in a row.
PodcastEpisode.objects.filter(feed=self.fb).delete()
self.assertEqual(self.radio(after=a9.id), ['a8', 'a7'])
def test_heard_leaves_no_trace_but_the_radio_moves_on(self):
a1 = self.ep(self.fa, 'a1', 1)
b2 = self.ep(self.fb, 'b2', 2)
self.assertEqual(self.heard(b2), ['a1'])
self.assertEqual(self.radio(), ['a1'])
self.assertFalse(EpisodeProgress.objects.exists())
self.assertFalse(PodcastQueue.objects.exists())
# The Strom and the history don't know about it.
r = self.client.get('/podcasts/episodes/', {'filter': 'history'}).json()
self.assertEqual(r['total'], 0)
r = self.client.get('/podcasts/episodes/', {'filter': 'stream,unplayed'}).json()
self.assertEqual(r['total'], 2)
self.heard(b2) # twice is fine
self.assertEqual(PodcastRadioHeard.objects.count(), 1)
self.heard(a1)
self.assertEqual(self.radio(), [])
def test_others_episodes_and_bad_requests(self):
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
theirs = self.ep(fbob, 'x', 1)
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': theirs.id}), content_type='application/json')
self.assertEqual(r.status_code, 404)
r = self.client.post('/podcasts/radio/heard/', '{}', content_type='application/json')
self.assertEqual(r.status_code, 400)
self.assertEqual(self.client.get('/podcasts/radio/', {'after': 'x'}).status_code, 400)
self.assertEqual(self.radio(), [])
self.client.logout()
self.assertEqual(self.client.get('/podcasts/radio/').status_code, 401)
def test_player_state_says_radio(self):
a1 = self.ep(self.fa, 'a1', 1)
body = {'device_id': 'phone', 'device_name': 'Handy', 'kind': 'podcast', 'episode_id': a1.id,
'playing': True, 'position_ms': 1000, 'start': True, 'podcast_radio': True}
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json()['podcast_radio'])
self.assertTrue(PlayerState.objects.get(user=self.user).podcast_radio)
body['podcast_radio'] = False
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
self.assertFalse(r.json()['podcast_radio'])
self.assertFalse(EpisodeProgress.objects.exists())

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from . import audiobooks, discover, folders, views from . import audiobooks, discover, folders, radio, views
urlpatterns = [ urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'), path('search/', views.podcast_search, name='podcast_search'),
@ -39,6 +39,8 @@ urlpatterns = [
path('later/remove/', views.later_remove, name='podcast_later_remove'), path('later/remove/', views.later_remove, name='podcast_later_remove'),
path('lists/', views.lists_switch, name='podcast_lists_switch'), path('lists/', views.lists_switch, name='podcast_lists_switch'),
path('stream/seen/', views.stream_seen, name='podcast_stream_seen'), path('stream/seen/', views.stream_seen, name='podcast_stream_seen'),
path('radio/', radio.radio, name='podcast_radio'),
path('radio/heard/', radio.radio_heard, name='podcast_radio_heard'),
# Stöbern (docs/podcasts.md, podcasts/discover.py) # Stöbern (docs/podcasts.md, podcasts/discover.py)
path('discover/search/', discover.search_view, name='podcast_discover_search'), path('discover/search/', discover.search_view, name='podcast_discover_search'),
path('discover/similar/<int:pk>/', discover.similar_view, name='podcast_discover_similar'), path('discover/similar/<int:pk>/', discover.similar_view, name='podcast_discover_similar'),

View file

@ -33,7 +33,7 @@ from . import youtube as youtube_mod
from . import folders as folders_mod from . import folders as folders_mod
from .models import ( from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
PodcastQueue, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
) )
from .shownotes import extract_timestamps, sanitize_html from .shownotes import extract_timestamps, sanitize_html
@ -1602,6 +1602,8 @@ EPISODE_FILTERS = {
'all': Q(), 'all': Q(),
'unplayed': Q(played=False), 'unplayed': Q(played=False),
'in_progress': Q(position_seconds__gt=0, played=False), 'in_progress': Q(position_seconds__gt=0, played=False),
# Neither played nor listened into (the Podcast-Radio's, docs/podcasts.md).
'untouched': Q(position_seconds=0, played=False),
'played': Q(played=True), 'played': Q(played=True),
'favorites': Q(favorite=True), 'favorites': Q(favorite=True),
'history': Q(played=True) | Q(position_seconds__gt=0), 'history': Q(played=True) | Q(position_seconds__gt=0),
@ -1638,7 +1640,7 @@ def _int_param(request, name, default):
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT, def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
sort=None, ids=None, notes=False, folder=None): sort=None, ids=None, notes=False, folder=None, radio=False):
"""(episodes, total) for the episode list. Two queries, whatever the """(episodes, total) for the episode list. Two queries, whatever the
page size: the count and the page. The user's progress row comes in by page size: the count and the page. The user's progress row comes in by
a join, queue membership by a subquery. No shownotes (up to 50 KB each). a join, queue membership by a subquery. No shownotes (up to 50 KB each).
@ -1648,7 +1650,7 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
the newest first, in the history the last listened first. ids: only these the newest first, in the history the last listened first. ids: only these
episodes (the ones downloaded on a device). notes: `q` also matches the episodes (the ones downloaded on a device). notes: `q` also matches the
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
in none).""" in none). radio: not the ones the Podcast-Radio is done with (PodcastRadioHeard)."""
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name) names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all'] names = [n for n in names if n and n != 'all']
qs = ( qs = (
@ -1673,6 +1675,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
if (folder in (fs or []) if folder else not fs)]) if (folder in (fs or []) if folder else not fs)])
if ids is not None: if ids is not None:
qs = qs.filter(id__in=ids) qs = qs.filter(id__in=ids)
if radio:
qs = qs.exclude(Exists(PodcastRadioHeard.objects.filter(user=user, episode=OuterRef('pk'))))
if feed_id is None and ids is None: # audiobooks are listened to in the library if feed_id is None and ids is None: # audiobooks are listened to in the library
qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK) qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK)
order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')] order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
@ -2695,6 +2699,7 @@ def _player_json(user, p, now=None):
'seq': p.seq, 'seq': p.seq,
'kind': p.kind, 'kind': p.kind,
'episode': episode, 'episode': episode,
'podcast_radio': bool(episode) and p.podcast_radio,
'station': station, 'station': station,
'position_ms': p.position_ms, 'position_ms': p.position_ms,
'playing': p.playing, 'playing': p.playing,
@ -2798,6 +2803,8 @@ def player_report(request):
p, p,
kind=kind, kind=kind,
episode_id=episode_id, episode_id=episode_id,
# The episode plays in the Podcast-Radio (docs/podcasts.md).
podcast_radio=kind == PlayerState.PODCAST and body.get('podcast_radio') is True,
station_url=station[0], station_name=station[1], station_id=station[2], station_url=station[0], station_name=station[1], station_id=station[2],
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0, position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
playing=playing and bool(kind), playing=playing and bool(kind),

View file

@ -673,8 +673,17 @@
store('book', b); store('book', b);
} }
// The Podcast-Radio (docs/podcasts.md): the Strom played like a station. An episode it plays
// is an entry with radio: true; the radio keeps the next ones (from /podcasts/radio/), the place
// of the one it plays and what it was done with lately — here only: nothing else learns of it
// (no progress, no lists). Playing anything else switches it off.
let podRadio = load('podradio', null); // {next: [entry, …], pos: {id, seconds}, done: [id, …]}
if (!podRadio || !Array.isArray(podRadio.next)) podRadio = null;
let radioFetchedAt = 0;
const curId = () => (cur ? cur.episode__id : null); const curId = () => (cur ? cur.episode__id : null);
const curFeed = () => (cur ? cur.episode__feed__id : null); const curFeed = () => (cur ? cur.episode__feed__id : null);
const onRadio = () => !!(cur && cur.radio && podRadio);
function durMs() { function durMs() {
if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000; if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000;
@ -689,6 +698,10 @@
// Where this episode picks up: its place (the queue knows it best), past the podcast's intro. // Where this episode picks up: its place (the queue knows it best), past the podcast's intro.
function resumeMs() { function resumeMs() {
if (onRadio()) {
const at = podRadio.pos && podRadio.pos.id === cur.episode__id ? podRadio.pos.seconds * 1000 : 0;
return Math.max(at > 5000 ? at : 0, introMs(curFeed()));
}
const q = cur ? entry(cur.episode__id) : null; const q = cur ? entry(cur.episode__id) : null;
const resume = ((q || cur || {}).position_seconds || 0) * 1000; const resume = ((q || cur || {}).position_seconds || 0) * 1000;
return Math.max(resume > 5000 ? resume : 0, introMs(curFeed())); return Math.max(resume > 5000 ? resume : 0, introMs(curFeed()));
@ -701,6 +714,7 @@
} }
function nextEntry() { function nextEntry() {
if (onRadio()) return podRadio.next[0] || null; // the radio goes on with what the server said
const inBook = bookIndex(curId()); const inBook = bookIndex(curId());
if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter
const q = queueOf(state); const q = queueOf(state);
@ -711,12 +725,18 @@
function saveNow(id, send) { function saveNow(id, send) {
lastSaved = Date.now(); lastSaved = Date.now();
if (onRadio() && id === curId()) { // the radio's place, for the radio only
podRadio.pos = {id, seconds: Math.floor(posMs() / 1000)};
store('podradio', podRadio);
return;
}
saveProgress(id, Math.floor(posMs() / 1000)); saveProgress(id, Math.floor(posMs() / 1000));
if (send) flush(); if (send) flush();
} }
// An episode is done: played, out of the queue (setting). True when the sleep timer says stop. // An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
function finished(id) { function finished(id) {
if (onRadio() && id === curId()) { radioDone(id); return sleep.episodeFinished(); }
const at = bookIndex(id); const at = bookIndex(id);
if (at >= 0) { book.chapters[at].played = true; store('book', book); } if (at >= 0) { book.chapters[at].played = true; store('book', book); }
markPlayedAll([id], true, prefs.remove_played); markPlayedAll([id], true, prefs.remove_played);
@ -830,13 +850,25 @@
const prev = curId(); const prev = curId();
let stop = false; let stop = false;
if (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left if (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left
const fromRadio = onRadio();
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev); // The radio's episode: ended or skipped, the radio is done with it; else it keeps its place.
if (fromRadio) {
if (auto || skipped || almostEnded()) radioDone(prev);
if (auto || almostEnded()) stop = sleep.episodeFinished();
else if (!skipped) saveNow(prev, false);
} else if (auto || almostEnded()) stop = finished(prev);
else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); } else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
else saveNow(prev, true); else saveNow(prev, true);
} }
unload(); unload();
if (podRadio && !e.radio) setPodRadio(null); // something else plays: the radio is off
cur = Object.assign({}, e); cur = Object.assign({}, e);
if (e.radio) {
podRadio.next = podRadio.next.filter(x => x.episode__id !== e.episode__id);
store('podradio', podRadio);
radioFetch();
}
startAt = null; startAt = null;
pausedAt = 0; pausedAt = 0;
buffering = false; buffering = false;
@ -917,6 +949,7 @@
// To the next episode: the one left is skipped (see goTo). // To the next episode: the one left is skipped (see goTo).
function next() { function next() {
if (onRadio()) { radioAdvance(curId(), false); return; }
const n = nextEntry(); const n = nextEntry();
if (n) goTo(n, false, wantPlay, true); if (n) goTo(n, false, wantPlay, true);
} }
@ -942,7 +975,7 @@
} }
function playEpisode(id) { function playEpisode(id) {
if (curId() === id) { resume(); return; } if (curId() === id) { radioCommit(); resume(); return; } // played by hand: no longer the radio's
const e = entry(id); const e = entry(id);
if (e) goTo(e, false, true); if (e) goTo(e, false, true);
} }
@ -965,6 +998,7 @@
const dur = durMs(); const dur = durMs();
if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return; if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
outroDone = id; outroDone = id;
if (onRadio()) { radioAdvance(id, true); return; }
const n = nextEntry(); // before finished() takes this one out of the queue const n = nextEntry(); // before finished() takes this one out of the queue
if (finished(id) || !n) pause(); if (finished(id) || !n) pause();
else goTo(n, false, true); else goTo(n, false, true);
@ -973,6 +1007,7 @@
function onEnded() { function onEnded() {
const id = curId(); const id = curId();
if (id == null) return; if (id == null) return;
if (onRadio()) { if (id !== outroDone) radioAdvance(id, true); return; }
const n = nextEntry(); const n = nextEntry();
if (n) { goTo(n, true, true); return; } if (n) { goTo(n, true, true); return; }
if (id !== outroDone) finished(id); if (id !== outroDone) finished(id);
@ -1058,6 +1093,7 @@
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true); if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id); if (playing) skipOutro(id);
if (playing && onRadio() && Date.now() - radioFetchedAt > (durMs() - posMs() < 90000 ? 120000 : 600000)) radioFetch();
} }
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient). // The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient).
sleep.tick(playing || radioPlaying() || ambientPlaying()); sleep.tick(playing || radioPlaying() || ambientPlaying());
@ -1067,6 +1103,121 @@
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report(); if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
}, 1000); }, 1000);
// ---- the Podcast-Radio (docs/podcasts.md) -----------------------------------------------------
function setPodRadio(r) {
podRadio = r;
store('podradio', r);
}
const radioEntry = ep => Object.assign({}, asQueueEntry(ep), {radio: true, position_seconds: 0});
// The next ones from the server, after the episode playing (not it, not its podcast next).
function radioFetch() {
radioFetchedAt = Date.now();
const after = onRadio() ? curId() : null;
const q = new URLSearchParams({limit: '5'});
if (after) q.set('after', String(after));
// Done here lately: maybe not on the server yet (the mixing must not count them).
if (podRadio && podRadio.done && podRadio.done.length) q.set('skip', podRadio.done.slice(0, 20).join(','));
return fetch('/podcasts/radio/?' + q, {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(d => {
if (!d || !podRadio) return null; // couldn't ask (offline): the caller keeps what it knew
const done = podRadio.done || [];
podRadio.next = (d.episodes || []).filter(e => e.id !== curId() && done.indexOf(e.id) < 0).map(radioEntry);
store('podradio', podRadio);
report();
return podRadio.next;
});
}
// Ended or skipped: the radio is done with it (kept here too, until the server has it).
function radioDone(id) {
if (!podRadio) return;
podRadio.done = [id].concat((podRadio.done || []).filter(x => x !== id)).slice(0, 50);
podRadio.next = podRadio.next.filter(x => x.episode__id !== id);
if (podRadio.pos && podRadio.pos.id === id) podRadio.pos = null;
store('podradio', podRadio);
queueOp('/podcasts/radio/heard/', {episode_id: id}, 'radio-heard:' + id);
flush();
}
// The radio moves on from `id` (ended: auto, or skipped): done with it, and what comes next is
// asked anew, so an episode that came out a minute ago goes first; offline, what it knew.
function radioAdvance(id, auto) {
const known = podRadio.next.filter(x => x.episode__id !== id)[0] || null;
const play = auto || wantPlay;
radioDone(id);
outroDone = id; // goTo doesn't count it again
if (auto && sleep.episodeFinished()) { pause(); return; }
radioFetch().then(list => {
if (curId() !== id || !onRadio()) return; // something else plays meanwhile
const n = list ? list[0] : known;
if (n) { goTo(n, false, play); return; }
pause();
toast('Podcast-Radio: gerade nichts Ungehörtes im Strom');
});
}
// 📻: the radio plays (goes on with its episode, or starts with the newest).
function radioStart() {
// Its episode, unless the radio is done with it (it ran out of episodes there).
if (onRadio() && (podRadio.done || []).indexOf(curId()) < 0) { resume(); return; }
setPodRadio({next: [], pos: null, done: (podRadio && podRadio.done) || []});
radioFetch().then(list => {
if (!podRadio) return;
if (!list) { setPodRadio(null); toast('Keine Verbindung zum Server'); return; }
if (!list.length) { setPodRadio(null); toast('Im Strom ist gerade nichts Ungehörtes'); return; }
goTo(list[0], false, true);
});
}
// "Normal weiterhören": the episode leaves the radio, first in the Wiedergabeliste at its place;
// from now on it plays as usual (with progress).
function radioCommit() {
if (!onRadio()) return;
const id = curId();
const e = Object.assign({}, cur, {position_seconds: Math.floor(posMs() / 1000)});
delete e.radio;
setPodRadio(null);
cur = e;
storePlayer();
enqueue([e], 'front', null);
saveProgress(id, e.position_seconds);
flush();
setSession();
report();
syncSoon();
}
// The radio's episode from another device (a transfer, or adopting it): the radio here.
function loadRadioAt(ep, at) {
at = Math.max(0, Math.round(at || 0));
if (!podRadio) setPodRadio({next: [], pos: null, done: []});
if (curId() !== ep.id || !cur.radio) {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
cur = radioEntry(ep);
wantPlay = false;
buffering = false;
error = null;
lastDur = 0;
storePlayer();
} else if (loaded && audio.readyState > 0) {
try { audio.currentTime = at / 1000; } catch (e) {}
}
startAt = loaded ? null : at;
mustReach = null;
pausedAt = 0;
lastPos = at;
podRadio.pos = {id: ep.id, seconds: Math.floor(at / 1000)};
store('podradio', podRadio);
radioFetch();
report();
}
// ---- skip silence ---------------------------------------------------------------------- // ---- skip silence ----------------------------------------------------------------------
// A browser can't listen to most podcast servers' audio (no CORS), so the server works out // A browser can't listen to most podcast servers' audio (no CORS), so the server works out
// once per episode which parts of its pauses go (podcasts/silence.py, the same rules as the // once per episode which parts of its pauses go (podcasts/silence.py, the same rules as the
@ -1448,7 +1599,7 @@
} }
if (cur) { if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())), return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz}; playing: wantPlay, speed: speedFor(curFeed()), sleep: zz, podcast_radio: onRadio()};
} }
return {kind: '', position_ms: 0, playing: false}; return {kind: '', position_ms: 0, playing: false};
} }
@ -1614,6 +1765,7 @@
} else { } else {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true); if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload(); unload();
if (podRadio) setPodRadio(null);
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep)); cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
startAt = at; startAt = at;
wantPlay = false; wantPlay = false;
@ -1635,7 +1787,7 @@
// (another device played on): never while it plays here, never over a place // (another device played on): never while it plays here, never over a place
// of ours that isn't saved yet, not for a few seconds' difference. // of ours that isn't saved yet, not for a few seconds' difference.
function movePausedTo(id, at) { function movePausedTo(id, at) {
if (curId() !== id || wantPlay) return false; if (curId() !== id || wantPlay || onRadio()) return false;
if (outbox.some(op => op.key === 'progress:' + id)) return false; if (outbox.some(op => op.key === 'progress:' + id)) return false;
if (Math.abs(at - posMs()) <= 5000) return false; if (Math.abs(at - posMs()) <= 5000) return false;
loadAt({id}, at); loadAt({id}, at);
@ -1657,6 +1809,9 @@
st = st || sync.state; st = st || sync.state;
if (st && st.kind === 'radio' && st.station) { if (st && st.kind === 'radio' && st.station) {
playStation(st.station.url, st.station.name || '', st.station.id || null); playStation(st.station.url, st.station.name || '', st.station.id || null);
} else if (st && st.kind === 'podcast' && st.episode && st.podcast_radio) {
loadRadioAt(st.episode, placeOf(st));
resume();
} else if (st && st.kind === 'podcast' && st.episode) { } else if (st && st.kind === 'podcast' && st.episode) {
loadAt(st.episode, placeOf(st)); loadAt(st.episode, placeOf(st));
resume(); resume();
@ -1668,7 +1823,9 @@
// The active device is gone and we play nothing: its item becomes ours, paused. // The active device is gone and we play nothing: its item becomes ours, paused.
function adopt(st) { function adopt(st) {
if (wantPlay || radioPlaying()) return; if (wantPlay || radioPlaying()) return;
if (st.kind === 'podcast' && st.episode) { if (st.kind === 'podcast' && st.episode && st.podcast_radio) {
loadRadioAt(st.episode, st.position_ms);
} else if (st.kind === 'podcast' && st.episode) {
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms); if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
else loadAt(st.episode, st.position_ms); else loadAt(st.episode, st.position_ms);
} else if (st.kind === 'radio' && st.station) { } else if (st.kind === 'radio' && st.station) {
@ -1792,6 +1949,10 @@
o.dur = Math.round(durMs()); o.dur = Math.round(durMs());
const at = bookIndex(cur.episode__id); const at = bookIndex(cur.episode__id);
if (at >= 0) o.book = {feed_id: book.feed_id, index: at, chapters: book.chapters.length}; if (at >= 0) o.book = {feed_id: book.feed_id, index: at, chapters: book.chapters.length};
if (onRadio()) {
const n = podRadio.next[0];
o.radio = {next: n ? {title: n.episode__title || '', feed: n.episode__feed__title || ''} : null};
}
o.playing = wantPlay; o.playing = wantPlay;
o.buffering = buffering && wantPlay; o.buffering = buffering && wantPlay;
if (error) o.error = error; if (error) o.error = error;
@ -1819,7 +1980,7 @@
try { try {
ms.metadata = new MediaMetadata({ ms.metadata = new MediaMetadata({
title: plain(cur.episode__title), title: plain(cur.episode__title),
artist: plain(cur.episode__feed__title), artist: (onRadio() ? 'Podcast-Radio · ' : '') + plain(cur.episode__feed__title),
album: plain(cur.episode__feed__title), album: plain(cur.episode__feed__title),
artwork: art ? [{src: art}] : [], artwork: art ? [{src: art}] : [],
}); });
@ -1939,6 +2100,11 @@
flush(); flush();
}, },
podcastNext: () => next(), podcastNext: () => next(),
// The Podcast-Radio (docs/podcasts.md): play it, or take its episode out of it.
podcastRadio: () => radioStart(),
podcastRadioCommit: () => radioCommit(),
// + while the radio plays it: onto the Merkliste (the radio plays on).
podcastRadioLater: sent(() => { if (onRadio()) { const e = Object.assign({}, cur); delete e.radio; laterAdd([e]); } }),
podcastToggle: () => (wantPlay ? pause() : resume()), podcastToggle: () => (wantPlay ? pause() : resume()),
podcastPause: () => { if (wantPlay) pause(); }, podcastPause: () => { if (wantPlay) pause(); },
podcastSeek: ms => seek(Number(ms)), podcastSeek: ms => seek(Number(ms)),
@ -2076,12 +2242,15 @@
const was = load('player', null); const was = load('player', null);
if (was && was.entry && was.entry.episode__id != null) { if (was && was.entry && was.entry.episode__id != null) {
const at = bookIndex(was.entry.episode__id); const at = bookIndex(was.entry.episode__id);
cur = entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry; cur = was.entry.radio && podRadio ? was.entry
: entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
if (cur.radio && !podRadio) delete cur.radio;
lastPos = resumeMs(); lastPos = resumeMs();
} }
// Keep the place when the page goes away; send what's pending when the connection is back. // Keep the place when the page goes away; send what's pending when the connection is back.
const leaving = () => { const leaving = () => {
if (onRadio()) { if (loaded && audio.readyState > 0) saveNow(cur.episode__id, false); return; }
if (cur && loaded && audio.readyState > 0) { if (cur && loaded && audio.readyState > 0) {
saveProgress(cur.episode__id, Math.floor(posMs() / 1000)); saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
if (navigator.sendBeacon) { if (navigator.sendBeacon) {

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v152'; const CACHE = 'diora-v153';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 6cfec49 2026-10-25: web-native/static/native-podcast.js // Copied from diora-android 7f37d4e 2026-10-25: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // 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. // and copy again. The browser host is static/js/podcasts-web.js.
@ -384,8 +384,10 @@
}; };
} }
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book). // "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book);
// "Podcast-Radio · <podcast>" while the radio plays (playerState().radio, diora-web docs/podcasts.md).
function bookLine(p) { function bookLine(p) {
if (p && p.radio) return 'Podcast-Radio · ' + plain(p.feed || '');
const b = p && p.book; const b = p && p.book;
if (!b || !b.chapters) return ''; if (!b || !b.chapters) return '';
return `Kapitel ${b.index + 1}/${b.chapters}` + (b.title || p.feed ? ' · ' + plain(b.title || p.feed) : ''); return `Kapitel ${b.index + 1}/${b.chapters}` + (b.title || p.feed ? ' · ' + plain(b.title || p.feed) : '');
@ -3365,6 +3367,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
const head = el('div', 'nui-pod-head'); const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`), head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`),
listTools([ listTools([
view === 'stream' && N.podcastRadio && button('📻', 'Podcast-Radio: immer die neueste ungehörte Folge – ohne Spuren in Listen und Verlauf',
() => N.podcastRadio(), 'nui-pod-radiobtn'),
view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'), view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'),
button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))), button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))),
button('☐', 'Mehrere auswählen', () => startSelecting()), button('☐', 'Mehrere auswählen', () => startSelecting()),
@ -4715,7 +4719,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
closePlayer(); closePlayer();
window.__dioraPodcastOpenFeed(id); window.__dioraPodcastOpenFeed(id);
}); });
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox); // The Podcast-Radio: what comes next; + (Merkliste) and "Normal weiterhören" (diora-web docs/podcasts.md).
const radioBox = el('div', 'nui-pod-radiobox');
radioBox.hidden = true;
const radioActs = el('div', 'nui-pod-radioacts');
radioActs.append(
button('+ Merkliste', 'Auf die Merkliste (das Radio spielt weiter)', () => { N.podcastRadioLater(); toast('Auf der Merkliste'); }, 'nui-pod-radiolater'),
button('Normal weiterhören', 'Aus dem Radio nehmen: zuerst in die Wiedergabeliste, ab hier mit Verlauf',
() => { N.podcastRadioCommit(); toast('Läuft jetzt normal – zuerst in der Wiedergabeliste'); }, 'nui-pod-radiocommit'),
);
radioBox.append(el('div', 'nui-pod-radionext'), radioActs);
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox, radioBox);
const range = el('input', 'nui-pod-seek'); const range = el('input', 'nui-pod-seek');
range.type = 'range'; range.type = 'range';
range.min = 0; range.min = 0;
@ -4873,6 +4887,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
markChapter(sheet, (player.pos || 0) / 1000); markChapter(sheet, (player.pos || 0) / 1000);
sheet.querySelector('.nui-pod-player-title').textContent = player.title || ''; sheet.querySelector('.nui-pod-player-title').textContent = player.title || '';
sheet.querySelector('.nui-pod-player-feed').textContent = bookLine(player) || player.feed || ''; sheet.querySelector('.nui-pod-player-feed').textContent = bookLine(player) || player.feed || '';
const radioBox = sheet.querySelector('.nui-pod-radiobox');
if (radioBox) {
radioBox.hidden = !player.radio || !N.podcastRadioCommit;
const n = player.radio && player.radio.next;
radioBox.querySelector('.nui-pod-radionext').textContent = n ? `Danach: ${plain(n.feed)} – ${plain(n.title)}` : '';
}
const nx = sheet.querySelector('.nui-pod-next');
if (nx) {
if (!nx.dataset.title) nx.dataset.title = nx.title;
nx.title = player.radio ? 'Überspringen – das Radio spielt die nächste' : nx.dataset.title;
}
const range = sheet.querySelector('.nui-pod-seek'); const range = sheet.querySelector('.nui-pod-seek');
const dur = Math.round((player.dur || 0) / 1000); const dur = Math.round((player.dur || 0) / 1000);
const pos = Math.round((player.pos || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000);
@ -5107,6 +5132,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
function finishAndNext() { function finishAndNext() {
const id = currentId(); const id = currentId();
if (!id) return; if (!id) return;
if (player.radio) { N.podcastNext(); return; } // the radio is done with it, nothing else
const ids = queue().map(q => q.episode__id); const ids = queue().map(q => q.episode__id);
const at = ids.indexOf(id); const at = ids.indexOf(id);
const last = at < 0 ? !ids.length : at === ids.length - 1; const last = at < 0 ? !ids.length : at === ids.length - 1;

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 1c1ec4e 2026-10-19: the podcast parts of web-native/static/native.css /* Copied from diora-android 7f37d4e 2026-10-25: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -803,6 +803,14 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
/* the player's chapters, right under the title */ /* the player's chapters, right under the title */
.nui-pod-chapbox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 2px 4px 2px 10px; } .nui-pod-chapbox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 2px 4px 2px 10px; }
.nui-pod-chapbox[hidden] { display: none; } .nui-pod-chapbox[hidden] { display: none; }
/* the Podcast-Radio (diora-web docs/podcasts.md): what comes next, + and "Normal weiterhören" */
.nui-pod-radiobox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px 6px 10px; }
.nui-pod-radiobox[hidden] { display: none; }
.nui-pod-radionext { font-size: 12px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nui-pod-radionext:empty { display: none; }
.nui-pod-radioacts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.nui-pod-radioacts .nui-icon { width: auto !important; padding: 0 12px !important; font-size: 13px !important; border: 1px solid var(--border); border-radius: 16px; }
body.eink .nui-pod-radiobox, body.eink .nui-pod-radioacts .nui-icon { border-color: #000; }
.nui-pod-chaphead { display: flex; align-items: center; gap: 2px; min-height: 40px; } .nui-pod-chaphead { display: flex; align-items: center; gap: 2px; min-height: 40px; }
.nui-pod-chapcount { flex: 1; min-width: 0; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; } .nui-pod-chapcount { flex: 1; min-width: 0; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
.nui-pod-chapbtn { min-width: 40px !important; min-height: 40px !important; font-size: 16px !important; } .nui-pod-chapbtn { min-width: 40px !important; min-height: 40px !important; font-size: 16px !important; }