From a64c8012754eb56fbcb684186a5f7a20a6924efa Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 25 Oct 2026 13:17:09 +0100 Subject: [PATCH] =?UTF-8?q?Podcast-Radio:=20die=20neueste=20ungeh=C3=B6rte?= =?UTF-8?q?=20Strom-Folge,=20ohne=20Spuren=20(eigene=20Radio-geh=C3=B6rt-L?= =?UTF-8?q?iste,=20gemischt=20nach=20Podcasts,=20neue=20Folgen=20zuerst);?= =?UTF-8?q?=20Web-Player=20und=20Player-Sync;=20sw=20v153?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/podcasts.md | 32 ++-- podcasts/migrations/0025_podcast_radio.py | 33 ++++ podcasts/models.py | 18 +++ podcasts/radio.py | 88 +++++++++++ podcasts/test_radio.py | 107 +++++++++++++ podcasts/urls.py | 4 +- podcasts/views.py | 13 +- static/js/podcasts-web.js | 183 +++++++++++++++++++++- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 32 +++- static/podcasts/podcast-ui.css | 10 +- 11 files changed, 497 insertions(+), 25 deletions(-) create mode 100644 podcasts/migrations/0025_podcast_radio.py create mode 100644 podcasts/radio.py create mode 100644 podcasts/test_radio.py diff --git a/docs/podcasts.md b/docs/podcasts.md index 1ba4193..566f414 100644 --- a/docs/podcasts.md +++ b/docs/podcasts.md @@ -92,7 +92,7 @@ One controlled step out (Apple's episode search and fyyd; no key): 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. @@ -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 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. +- **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 `heard_365`), the Wiedergabeliste and Merkliste unchanged, nothing downloaded (it streams; a 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" (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). -- **Server**: `PodcastRadioHeard(user, episode, at)`, kept apart from progress, pruned after 60 - days. `GET /podcasts/radio/?limit=5` → the next episodes by the rule (`{episodes: [...]}`, the - rows of the episode lists); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. -- **Clients** (both at once): Android (`PodcastRadio.kt` beside `BookPlay.kt`: the playlist is - [current, next]; RadioService's `save()` / `markPlayed()` / `followQueue()` leave radio items - alone; reloads before each transition and every 10 minutes while playing — the server fetches - feeds every 15 minutes) and the web player (`podcasts-web.js`, so the browser and the desktop - app). Button "📻 Radio" at the top of the Strom; the mini player's ▶ goes on with the radio. +- **Server** (`podcasts/radio.py`): `PodcastRadioHeard(user, episode, at)`, kept apart from + progress and kept for good (pruned, an old episode would come back when the radio goes back + that far). `GET /podcasts/radio/?after=&skip=&limit=5` → the next episodes by + the rule (`{episodes: [...]}`, the rows of the episode lists; `after` isn't among them and its + podcast not first; `skip`: ones the device is done with whose /heard/ may still be on its + way); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. Filter `untouched` + (episode lists): neither played nor listened into. +- **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 · " 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. ## Moving over diff --git a/podcasts/migrations/0025_podcast_radio.py b/podcasts/migrations/0025_podcast_radio.py new file mode 100644 index 0000000..b5f6faa --- /dev/null +++ b/podcasts/migrations/0025_podcast_radio.py @@ -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')}, + }, + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index ddb7700..0ecdbda 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -290,6 +290,21 @@ class PodcastLater(models.Model): 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): """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 @@ -401,6 +416,9 @@ class PlayerState(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state') 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='+') + # 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_name = models.CharField(max_length=300, blank=True) station_id = models.IntegerField(null=True, blank=True) diff --git a/podcasts/radio.py b/podcasts/radio.py new file mode 100644 index 0000000..108dc30 --- /dev/null +++ b/podcasts/radio.py @@ -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=&skip=&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)}) diff --git a/podcasts/test_radio.py b/podcasts/test_radio.py new file mode 100644 index 0000000..74e72cb --- /dev/null +++ b/podcasts/test_radio.py @@ -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()) diff --git a/podcasts/urls.py b/podcasts/urls.py index 2433919..933f93e 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from . import audiobooks, discover, folders, views +from . import audiobooks, discover, folders, radio, views urlpatterns = [ path('search/', views.podcast_search, name='podcast_search'), @@ -39,6 +39,8 @@ urlpatterns = [ path('later/remove/', views.later_remove, name='podcast_later_remove'), path('lists/', views.lists_switch, name='podcast_lists_switch'), 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) path('discover/search/', discover.search_view, name='podcast_discover_search'), path('discover/similar//', discover.similar_view, name='podcast_discover_similar'), diff --git a/podcasts/views.py b/podcasts/views.py index f4fd573..29e9d70 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -33,7 +33,7 @@ from . import youtube as youtube_mod from . import folders as folders_mod from .models import ( 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 @@ -1602,6 +1602,8 @@ EPISODE_FILTERS = { 'all': Q(), 'unplayed': Q(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), 'favorites': Q(favorite=True), '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, - 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 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). @@ -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 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 - 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 = [n for n in names if n and n != 'all'] 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 ids is not None: 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 qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK) 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, 'kind': p.kind, 'episode': episode, + 'podcast_radio': bool(episode) and p.podcast_radio, 'station': station, 'position_ms': p.position_ms, 'playing': p.playing, @@ -2798,6 +2803,8 @@ def player_report(request): p, kind=kind, 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], position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0, playing=playing and bool(kind), diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index efa622a..35438c2 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -673,8 +673,17 @@ 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 curFeed = () => (cur ? cur.episode__feed__id : null); + const onRadio = () => !!(cur && cur.radio && podRadio); function durMs() { 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. 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 resume = ((q || cur || {}).position_seconds || 0) * 1000; return Math.max(resume > 5000 ? resume : 0, introMs(curFeed())); @@ -701,6 +714,7 @@ } function nextEntry() { + if (onRadio()) return podRadio.next[0] || null; // the radio goes on with what the server said const inBook = bookIndex(curId()); if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter const q = queueOf(state); @@ -711,12 +725,18 @@ function saveNow(id, send) { 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)); if (send) flush(); } // An episode is done: played, out of the queue (setting). True when the sleep timer says stop. function finished(id) { + if (onRadio() && id === curId()) { radioDone(id); return sleep.episodeFinished(); } const at = bookIndex(id); if (at >= 0) { book.chapters[at].played = true; store('book', book); } markPlayedAll([id], true, prefs.remove_played); @@ -830,13 +850,25 @@ const prev = curId(); let stop = false; 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 (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 saveNow(prev, true); } unload(); + if (podRadio && !e.radio) setPodRadio(null); // something else plays: the radio is off 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; pausedAt = 0; buffering = false; @@ -917,6 +949,7 @@ // To the next episode: the one left is skipped (see goTo). function next() { + if (onRadio()) { radioAdvance(curId(), false); return; } const n = nextEntry(); if (n) goTo(n, false, wantPlay, true); } @@ -942,7 +975,7 @@ } 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); if (e) goTo(e, false, true); } @@ -965,6 +998,7 @@ const dur = durMs(); if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return; outroDone = id; + if (onRadio()) { radioAdvance(id, true); return; } const n = nextEntry(); // before finished() takes this one out of the queue if (finished(id) || !n) pause(); else goTo(n, false, true); @@ -973,6 +1007,7 @@ function onEnded() { const id = curId(); if (id == null) return; + if (onRadio()) { if (id !== outroDone) radioAdvance(id, true); return; } const n = nextEntry(); if (n) { goTo(n, true, true); return; } if (id !== outroDone) finished(id); @@ -1058,6 +1093,7 @@ if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000; if (playing && Date.now() - lastSaved > 10000) saveNow(id, true); 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). sleep.tick(playing || radioPlaying() || ambientPlaying()); @@ -1067,6 +1103,121 @@ if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report(); }, 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 ---------------------------------------------------------------------- // 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 @@ -1448,7 +1599,7 @@ } if (cur) { 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}; } @@ -1614,6 +1765,7 @@ } else { if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true); unload(); + if (podRadio) setPodRadio(null); cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep)); startAt = at; wantPlay = false; @@ -1635,7 +1787,7 @@ // (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. 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 (Math.abs(at - posMs()) <= 5000) return false; loadAt({id}, at); @@ -1657,6 +1809,9 @@ st = st || sync.state; if (st && st.kind === 'radio' && st.station) { 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) { loadAt(st.episode, placeOf(st)); resume(); @@ -1668,7 +1823,9 @@ // The active device is gone and we play nothing: its item becomes ours, paused. function adopt(st) { 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); else loadAt(st.episode, st.position_ms); } else if (st.kind === 'radio' && st.station) { @@ -1792,6 +1949,10 @@ o.dur = Math.round(durMs()); const at = bookIndex(cur.episode__id); 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.buffering = buffering && wantPlay; if (error) o.error = error; @@ -1819,7 +1980,7 @@ try { ms.metadata = new MediaMetadata({ 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), artwork: art ? [{src: art}] : [], }); @@ -1939,6 +2100,11 @@ flush(); }, 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()), podcastPause: () => { if (wantPlay) pause(); }, podcastSeek: ms => seek(Number(ms)), @@ -2076,12 +2242,15 @@ const was = load('player', null); if (was && was.entry && was.entry.episode__id != null) { 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(); } // Keep the place when the page goes away; send what's pending when the connection is back. const leaving = () => { + if (onRadio()) { if (loaded && audio.readyState > 0) saveNow(cur.episode__id, false); return; } if (cur && loaded && audio.readyState > 0) { saveProgress(cur.episode__id, Math.floor(posMs() / 1000)); if (navigator.sendBeacon) { diff --git a/static/js/sw.js b/static/js/sw.js index e3547e3..df95340 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v152'; +const CACHE = 'diora-v153'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 70a6ab6..71aa62d 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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 // and copy again. The browser host is static/js/podcasts-web.js. @@ -384,8 +384,10 @@ }; } - // "Kapitel 7/24 · " while a chapter of an audiobook plays (playerState().book). + // "Kapitel 7/24 · " while a chapter of an audiobook plays (playerState().book); + // "Podcast-Radio · " while the radio plays (playerState().radio, diora-web docs/podcasts.md). function bookLine(p) { + if (p && p.radio) return 'Podcast-Radio · ' + plain(p.feed || ''); const b = p && p.book; if (!b || !b.chapters) return ''; 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'); head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`), 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'), button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))), button('☐', 'Mehrere auswählen', () => startSelecting()), @@ -4715,7 +4719,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi closePlayer(); 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'); range.type = 'range'; 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); sheet.querySelector('.nui-pod-player-title').textContent = player.title || ''; 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 dur = Math.round((player.dur || 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() { const id = currentId(); 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 at = ids.indexOf(id); const last = at < 0 ? !ids.length : at === ids.length - 1; diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 6df2a67..813df62 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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 */ .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; } +/* 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-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; }