From 54fbd58293b6248689d78731a0ac5aed415d0b1e Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 2 Oct 2026 18:46:55 +0200 Subject: [PATCH] =?UTF-8?q?Podcast-Routine=20aus=20AntennaPod:=20Abspiel-E?= =?UTF-8?q?instellungen=20f=C3=BCrs=20Konto,=20Listen=20und=20Stille=20pro?= =?UTF-8?q?=20Podcast;=20Oberfl=C3=A4che=20aus=20diora-android=20304abf0,?= =?UTF-8?q?=20sw=20v65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PodcastSettings (Migration 0014): skip_back / skip_forward (5–60 s), smart_played, speed_presets (0,5–3,5×), keep_skipped, delete_dequeue, mobile_downloads, mobile_streaming (allow/ask/never), times_by_speed; die ersten vier sind anfangs leer (null) und werden mit POST settings/ {"initial": {...}} nur dort gesetzt, wo noch nichts steht - GET/POST /podcasts/settings/ und state.settings mit allen Feldern, geprüft, nichts ändert sich bei einem ungültigen Wert - PodcastFeed: episode_sort, episode_filter, skip_silence in feeds//settings/ und den Feeds des States - Tempo pro Podcast bis 3,5× (Server und AntennaPod-Import) - AntennaPod-Import: Sortierung, Filter und Stille pro Podcast, soweit diora sie zeigen kann; nicht darstellbare werden gezählt - Browser-Host: Sprungweiten, "gehört" und keep_skipped aus dem Konto, Tempo bis 3,5×, neue Einstellungen werden gespeichert Co-Authored-By: Claude Opus 5.5 --- podcasts/antennapod.py | 59 +++- podcasts/migrations/0014_playback_routine.py | 72 +++++ podcasts/models.py | 50 +++ podcasts/test_antennapod.py | 49 ++- podcasts/tests.py | 125 +++++++- podcasts/views.py | 173 ++++++++-- static/js/podcasts-web.js | 65 +++- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 316 +++++++++++++++++-- 9 files changed, 821 insertions(+), 90 deletions(-) create mode 100644 podcasts/migrations/0014_playback_routine.py diff --git a/podcasts/antennapod.py b/podcasts/antennapod.py index 357d631..f20dc32 100644 --- a/podcasts/antennapod.py +++ b/podcasts/antennapod.py @@ -25,7 +25,7 @@ from urllib.parse import urlsplit from django.db import transaction from django.db.models import Q -from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings +from .models import MAX_SPEED, MIN_SPEED, EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings from .views import MAX_DESCRIPTION, _keep_sorted, _leave_inbox, _queue_order, _write_queue_order CHUNK = 500 # ids per IN (...) query and rows per bulk write @@ -43,7 +43,18 @@ SUBSCRIBED = 0 # Feeds.state MAX_FILESIZE = 4_000_000_000 SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words', - 'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify') + 'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify', + 'episode_sort', 'episode_filter', 'skip_silence') + +# Feeds.sort_order (SortOrder codes) -> episode_sort; 11 = the global order +# (not in the backup). The others (title Z-A, file name, size) diora can't show. +SORT_ORDERS = {1: 'old', 2: 'new', 3: 'title', 5: 'short', 6: 'long'} +GLOBAL_SORT = 11 +# Feeds.hide (FeedItemFilter, comma-separated) -> episode_filter +FILTERS = {'unplayed': 'unplayed', 'played': 'played', 'paused': 'in_progress', 'is_favorite': 'favorites', + 'queued': 'queued', 'downloaded': 'downloaded'} +# Feeds.feed_skip_silence (SkipSilence codes); 1 = the global setting +SKIP_SILENCE = {0: False, 2: True} # Columns read from the backup, with the value to assume if an older # AntennaPod didn't have the column yet. @@ -52,7 +63,7 @@ FEED_COLUMNS = { 'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '', 'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1, 'feed_skip_intro': 0, 'feed_skip_ending': 0, 'feed_volume_adaption': 0, 'auto_download': 1, - 'episode_notification': 0, 'state': SUBSCRIBED, + 'episode_notification': 0, 'state': SUBSCRIBED, 'sort_order': None, 'hide': '', 'feed_skip_silence': 1, } @@ -156,13 +167,36 @@ def _given_updated_at(): # Feed settings # --------------------------------------------------------------------------- +def episode_sort(code): + """Feeds.sort_order -> (episode_sort or None, representable). None and + the global order are no setting of the feed (representable, nothing to set).""" + try: + code = int(code) if code not in (None, '') else None + except (TypeError, ValueError): + return None, False + if code is None or code == GLOBAL_SORT: + return None, True + return SORT_ORDERS.get(code), code in SORT_ORDERS + + +def episode_filter(raw): + """Feeds.hide -> (episode_filter or None, representable): the filter only + if diora can show all of its terms (a part of it would be another list).""" + terms = [t.strip() for t in (raw or '').split(',') if t.strip()] + if not terms: + return None, True + if any(t not in FILTERS for t in terms): + return None, False + return list(dict.fromkeys(FILTERS[t] for t in terms)), True + + def feed_settings(ap, global_auto_download=False): """diora settings for one AntennaPod feed row: only those AntennaPod sets to something other than its default. Returns (settings, not_kept_updated).""" s = {} speed = ap['feed_playback_speed'] if speed is not None and speed > 0: # -1 = the global speed - s['playback_speed'] = min(max(round(float(speed), 2), 0.5), 3.0) + s['playback_speed'] = min(max(round(float(speed), 2), MIN_SPEED), MAX_SPEED) for src, dst in (('feed_skip_intro', 'skip_intro'), ('feed_skip_ending', 'skip_outro')): seconds = int(ap[src] or 0) if seconds > 0: @@ -195,6 +229,16 @@ def feed_settings(ap, global_auto_download=False): # (0, AntennaPod's default, leaves diora's setting alone). if ap['episode_notification']: s['notify'] = True + # The podcast's episode list: its order and filter, where diora has them. + sort, _ = episode_sort(ap['sort_order']) + if sort: + s['episode_sort'] = sort + filters, _ = episode_filter(ap['hide']) + if filters: + s['episode_filter'] = filters + # Skip silence: off or on for this podcast (the global setting: diora's stays). + if ap['feed_skip_silence'] in SKIP_SILENCE: + s['skip_silence'] = SKIP_SILENCE[ap['feed_skip_silence']] return s, not_kept_updated @@ -230,6 +274,8 @@ class Stats: skipped_feeds: list = field(default_factory=list) # (title, url) settings: dict = field(default_factory=lambda: {name: 0 for name in SETTING_FIELDS}) not_kept_updated: int = 0 + sort_unshown: int = 0 # per-feed orders / filters diora has no equivalent of + filter_unshown: int = 0 auto_download_on: int = 0 auto_download_explicit: int = 0 auto_download_global: int = 0 @@ -414,6 +460,8 @@ class AntennaPodImport: settings, not_kept_updated = feed_settings(ap, self.global_auto_download) st.not_kept_updated += not_kept_updated + st.sort_unshown += not episode_sort(ap['sort_order'])[1] + st.filter_unshown += not episode_filter(ap['hide'])[1] st.auto_download_explicit += ap['auto_download'] == AUTO_DOWNLOAD_ON st.auto_download_global += ap['auto_download'] == AUTO_DOWNLOAD_GLOBAL for name, value in settings.items(): @@ -774,6 +822,7 @@ SETTING_LABELS = { 'playback_speed': 'speed', 'skip_intro': 'skip intro', 'skip_outro': 'skip outro', 'volume': 'volume', 'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration', 'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications', + 'episode_sort': 'episode order', 'episode_filter': 'episode filter', 'skip_silence': 'skip silence', } @@ -794,6 +843,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download): + (f', {len(st.removed_titles)} only in diora unsubscribed (--replace)' if st.removed_titles else ''), f'Settings: changed: {settings}', f' {st.not_kept_updated} feeds not kept updated in AntennaPod -> new episodes "ignore"', + f' episode lists diora can\'t show that way (left as they are): {st.sort_unshown} orders, ' + f'{st.filter_unshown} filters', f'Auto-download: on for {st.auto_download_on} feeds ({st.auto_download_explicit} on in AntennaPod, ' f"{st.auto_download_global} use AntennaPod's global setting -> {_on(global_auto_download)} " f'by --global-auto-download); default for new subscriptions: {_on(st.auto_download_default)}' diff --git a/podcasts/migrations/0014_playback_routine.py b/podcasts/migrations/0014_playback_routine.py new file mode 100644 index 0000000..63d004e --- /dev/null +++ b/podcasts/migrations/0014_playback_routine.py @@ -0,0 +1,72 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0013_feed_notify'), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='episode_sort', + field=models.CharField(blank=True, max_length=10), + ), + migrations.AddField( + model_name='podcastfeed', + name='episode_filter', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='podcastfeed', + name='skip_silence', + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='skip_back', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='skip_forward', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='smart_played', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='speed_presets', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='keep_skipped', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='delete_dequeue', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='mobile_downloads', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='podcastsettings', + name='mobile_streaming', + field=models.CharField(choices=[('allow', 'Erlauben'), ('ask', 'Nachfragen'), ('never', 'Nie')], + default='allow', max_length=10), + ), + migrations.AddField( + model_name='podcastsettings', + name='times_by_speed', + field=models.BooleanField(default=False), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 30f4c85..acf81a4 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -1,6 +1,11 @@ from django.contrib.auth.models import User from django.db import models +# Playback speeds everywhere (the apps, a podcast's own speed, the presets): +# AntennaPod goes up to 3.5x. +MIN_SPEED = 0.5 +MAX_SPEED = 3.5 + class PodcastFeed(models.Model): # What happens to a new episode of this feed (one that passed the filter). @@ -54,6 +59,17 @@ class PodcastFeed(models.Model): # Devices tell about this feed's new episodes (a notification, like # AntennaPod's per-podcast "episode notifications"). notify = models.BooleanField(default=False) + # The podcast's episode list as the user left it on any device: its + # order ('' = newest first) and the filters that are on (all must match). + episode_sort = models.CharField(max_length=10, blank=True) + episode_filter = models.JSONField(default=list, blank=True) + # Skip silence in this podcast's episodes: null = as the device is set. + skip_silence = models.BooleanField(null=True, blank=True) + + # The vocabulary of episode_sort and episode_filter: the podcast page's + # sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS). + EPISODE_SORTS = ('new', 'old', 'long', 'short', 'title') + EPISODE_FILTERS = ('unplayed', 'in_progress', 'favorites', 'queued', 'downloaded', 'played') class Meta: unique_together = ('user', 'rss_url') @@ -244,6 +260,40 @@ class PodcastSettings(models.Model): # to download them again, then clears the list. import_downloads = models.JSONField(default=list, blank=True) + # Playback, the same on every device (AntennaPod's playback settings). + # Null = not set yet: each device keeps its own value; the first app that + # finds one unset sends its own ("initial" in POST settings/), so nobody's + # setting changes when it moves to the account. + SKIP_CHOICES = (5, 10, 15, 20, 30, 45, 60) + skip_back = models.PositiveSmallIntegerField(null=True, blank=True) # seconds + skip_forward = models.PositiveSmallIntegerField(null=True, blank=True) # seconds + # An episode counts as played this many seconds before its end. + smart_played = models.PositiveIntegerField(null=True, blank=True) + # The speeds the speed menu offers and the notification's speed button + # goes through, ascending; null = the apps' default list. + speed_presets = models.JSONField(null=True, blank=True) + MAX_PRESETS = 12 + # Skipping to the next episode (player, notification, headset, car) + # leaves the skipped one unplayed and in the queue. + keep_skipped = models.BooleanField(default=True) + # Deleting a download takes its episode out of the queue too. + delete_dequeue = models.BooleanField(default=True) + # Downloads asked for by hand may use mobile data (else they wait for + # Wi-Fi, like automatic ones). + mobile_downloads = models.BooleanField(default=True) + # Streaming an episode over mobile data: allowed, asked once per episode, + # or never (radio streams aren't affected). + STREAMING = [('allow', 'Erlauben'), ('ask', 'Nachfragen'), ('never', 'Nie')] + mobile_streaming = models.CharField(max_length=10, choices=STREAMING, default='allow') + # Remaining times and lengths are shown at the playback speed. + times_by_speed = models.BooleanField(default=False) + + # The playback settings above, as the API names them; the first four + # start out unset. + PLAYBACK_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets', 'keep_skipped', + 'delete_dequeue', 'mobile_downloads', 'mobile_streaming', 'times_by_speed') + UNSET_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets') + @classmethod def for_user(cls, user): obj, _ = cls.objects.get_or_create(user=user) diff --git a/podcasts/test_antennapod.py b/podcasts/test_antennapod.py index 2fe86b6..a5676db 100644 --- a/podcasts/test_antennapod.py +++ b/podcasts/test_antennapod.py @@ -32,7 +32,7 @@ class Backup: feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0, feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption INTEGER DEFAULT 0, auto_download INTEGER DEFAULT 0, episode_notification INTEGER DEFAULT 0, state INTEGER DEFAULT 0, - tags TEXT); + tags TEXT, sort_order TEXT, hide TEXT, feed_skip_silence INTEGER DEFAULT 1); CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER, read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER, item_identifier TEXT, image_url TEXT); @@ -211,7 +211,7 @@ class FeedImportTests(ImportTestCase): feeds = {f.rss_url.rsplit('/', 1)[1]: f for f in PodcastFeed.objects.filter(user=self.user)} fast, slow = feeds['fast'], feeds['slow'] self.assertEqual((fast.playback_speed, fast.volume, fast.min_duration, fast.new_episodes, fast.auto_queue), - (3.0, 2.5, 0, 'queue', True)) + (3.5, 2.5, 0, 'queue', True)) self.assertEqual((slow.playback_speed, slow.volume, slow.new_episodes, slow.auto_queue), (0.5, 1.5, 'inbox', False)) self.assertEqual((feeds['loud'].volume, feeds['quiet'].volume), (2.0, 0.5)) @@ -268,6 +268,51 @@ class FeedImportTests(ImportTestCase): 'https://example.org/on': True, 'https://example.org/off': False}) self.assertIn('notifications 1', out) + def test_episode_lists_and_skip_silence(self): + """Per podcast: its episode order and filter (where diora has them) and skipping silence.""" + self.diora_feed('https://example.org/mine', episode_sort='long', episode_filter=['favorites'], + skip_silence=True) + self.ap.feed('https://example.org/mine') # nothing set in AntennaPod: diora's own stay + self.ap.feed('https://example.org/series', sort_order='1', hide='unplayed', feed_skip_silence=0) + self.ap.feed('https://example.org/new', sort_order='2', hide='paused,is_favorite,downloaded,queued') + self.ap.feed('https://example.org/title', sort_order='3', hide='played', feed_skip_silence=2) + self.ap.feed('https://example.org/short', sort_order='5') + self.ap.feed('https://example.org/long', sort_order='6', feed_skip_silence=1) + self.ap.feed('https://example.org/global', sort_order='11', hide='') + self.ap.feed('https://example.org/z-a', sort_order='4', hide='unplayed,not_downloaded') + self.ap.feed('https://example.org/size', sort_order='10', hide='new') + + out = self.run_import() + + got = {f.rss_url.rsplit('/', 1)[1]: (f.episode_sort, f.episode_filter, f.skip_silence) + for f in PodcastFeed.objects.filter(user=self.user)} + self.assertEqual(got, { + 'mine': ('long', ['favorites'], True), + 'series': ('old', ['unplayed'], False), + 'new': ('new', ['in_progress', 'favorites', 'downloaded', 'queued'], None), + 'title': ('title', ['played'], True), + 'short': ('short', [], None), + 'long': ('long', [], None), + 'global': ('', [], None), + 'z-a': ('', [], None), # title Z-A, "not downloaded": nothing diora can show + 'size': ('', [], None), + }) + self.assertIn('episode order 5, episode filter 3, skip silence 2', out) + self.assertIn("episode lists diora can't show that way (left as they are): 2 orders, 2 filters", out) + # Through the API as the apps see it. + self.client.force_login(self.user) + feeds = {f['rss_url'].rsplit('/', 1)[1]: f for f in self.client.get('/podcasts/state/').json()['feeds']} + self.assertEqual((feeds['series']['episode_sort'], feeds['series']['episode_filter'], + feeds['series']['skip_silence']), ('old', ['unplayed'], False)) + + def test_backup_without_list_and_silence_columns(self): + for column in ('sort_order', 'hide', 'feed_skip_silence'): + self.ap.db.execute(f'ALTER TABLE Feeds DROP COLUMN {column}') + self.ap.feed('https://example.org/old') + self.run_import() + f = PodcastFeed.objects.get(user=self.user, rss_url='https://example.org/old') + self.assertEqual((f.episode_sort, f.episode_filter, f.skip_silence), ('', [], None)) + def test_backup_without_episode_notification_column(self): self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification') self.ap.feed('https://example.org/old') diff --git a/podcasts/tests.py b/podcasts/tests.py index 05d215f..732d347 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -12,6 +12,13 @@ from .views import FeedFetch, _refresh_feed _parse = feedparser.parse +# The account's playback settings as a new user has them. +PLAYBACK_DEFAULTS = { + 'skip_back': None, 'skip_forward': None, 'smart_played': None, 'speed_presets': None, + 'keep_skipped': True, 'delete_dequeue': True, 'mobile_downloads': True, 'mobile_streaming': 'allow', + 'times_by_speed': False, +} + def _ok(rss, **kw): """What _fetch_feed returns for a 200 with this RSS.""" @@ -327,7 +334,8 @@ class RefreshAllTests(TestCase): # Shownotes stay out of the one-request state (they can be large). self.assertNotIn('description', data['inbox'][0]) self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front', - 'keep_sorted': '', 'auto_download_default': False}) + 'keep_sorted': '', 'auto_download_default': False, + **PLAYBACK_DEFAULTS}) # --------------------------------------------------------------------------- @@ -1031,7 +1039,7 @@ class OpmlExportTests(TestCase): FEED_DEFAULTS = { 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, - 'notify': False, + 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, } @@ -1058,6 +1066,7 @@ class FeedSettingsTests(TestCase): 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'auto_queue': False, 'auto_download': False, 'notify': False, + 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, }) self.feed.refresh_from_db() self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), @@ -1071,12 +1080,34 @@ class FeedSettingsTests(TestCase): def test_every_allowed_volume_and_the_speed_limits(self): for v in (0.2, 0.5, 1.0, 1.5, 2.0, 2.5): self.assertEqual(self.post({'volume': v}).json()['volume'], v) - for s in (0.5, 3, 3.0): + for s in (0.5, 3, 3.0, 3.5): self.assertEqual(self.post({'playback_speed': s}).json()['playback_speed'], s) + def test_episode_list_and_skip_silence(self): + r = self.post({'episode_sort': 'old', 'episode_filter': ['unplayed', 'favorites', 'unplayed'], + 'skip_silence': False}) + self.assertEqual((r.json()['episode_sort'], r.json()['episode_filter'], r.json()['skip_silence']), + ('old', ['unplayed', 'favorites'], False)) + self.feed.refresh_from_db() + self.assertEqual((self.feed.episode_sort, self.feed.episode_filter, self.feed.skip_silence), + ('old', ['unplayed', 'favorites'], False)) + for sort in PodcastFeed.EPISODE_SORTS: + self.assertEqual(self.post({'episode_sort': sort}).json()['episode_sort'], sort) + # Back to the defaults: newest first, all episodes, as the device is set. + r = self.post({'episode_sort': '', 'episode_filter': [], 'skip_silence': None}) + self.assertEqual((r.json()['episode_sort'], r.json()['episode_filter'], r.json()['skip_silence']), ('', [], None)) + self.assertTrue(self.post({'skip_silence': True}).json()['skip_silence']) + self.assertEqual(self.post({'episode_filter': list(PodcastFeed.EPISODE_FILTERS)}).json()['episode_filter'], + list(PodcastFeed.EPISODE_FILTERS)) + state = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}[self.feed.id] + self.assertEqual((state['episode_sort'], state['episode_filter'], state['skip_silence']), + ('', list(PodcastFeed.EPISODE_FILTERS), True)) + def test_invalid_values_name_the_field_and_change_nothing(self): bad = [ - ('playback_speed', 0.4), ('playback_speed', 3.5), ('playback_speed', 'fast'), ('playback_speed', True), + ('playback_speed', 0.4), ('playback_speed', 3.6), ('playback_speed', 'fast'), ('playback_speed', True), + ('episode_sort', 'played'), ('episode_sort', 1), ('episode_filter', 'unplayed'), + ('episode_filter', ['history']), ('episode_filter', [1]), ('skip_silence', 1), ('skip_silence', 'true'), ('skip_intro', -1), ('skip_intro', 601), ('skip_intro', 1.5), ('skip_intro', '10'), ('skip_intro', None), ('skip_outro', 601), ('volume', 0.7), ('volume', '1.0'), ('volume', None), ('new_episodes', 'later'), ('new_episodes', None), @@ -2384,3 +2415,89 @@ class BulkFavoriteTests(TestCase): self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405) self.client.logout() self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401) + + +class PlaybackSettingsTests(TestCase): + """The account's playback settings: skip lengths, speed presets, what + skipping and deleting do, mobile data, times at the speed.""" + + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + + def post(self, body): + return self.client.post('/podcasts/settings/', json.dumps(body), content_type='application/json') + + def settings(self): + return {k: getattr(PodcastSettings.for_user(self.user), k) for k in PLAYBACK_DEFAULTS} + + def test_defaults(self): + r = self.client.get('/podcasts/settings/').json() + self.assertEqual({k: r[k] for k in PLAYBACK_DEFAULTS}, PLAYBACK_DEFAULTS) + self.assertEqual(r['skip_choices'], [5, 10, 15, 20, 30, 45, 60]) + self.assertEqual(r['speed_range'], [0.5, 3.5]) + + def test_change_all(self): + body = {'skip_back': 10, 'skip_forward': 30, 'smart_played': 30, 'speed_presets': [3, 1, 2.5, 2, 2.0], + 'keep_skipped': False, 'delete_dequeue': False, 'mobile_downloads': False, + 'mobile_streaming': 'ask', 'times_by_speed': True} + r = self.post(body) + self.assertEqual(r.status_code, 200, r.content) + expected = {**body, 'speed_presets': [1.0, 2.0, 2.5, 3.0]} + self.assertEqual({k: r.json()[k] for k in PLAYBACK_DEFAULTS}, expected) + self.assertEqual(self.settings(), expected) + self.assertEqual({k: self.client.get('/podcasts/state/').json()['settings'][k] for k in PLAYBACK_DEFAULTS}, + expected) + # Only what is sent changes; null unsets again. + r = self.post({'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None}) + self.assertEqual({k: r.json()[k] for k in ('mobile_streaming', 'speed_presets', 'skip_back', 'skip_forward')}, + {'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None, 'skip_forward': 30}) + + def test_presets_up_to_three_and_a_half(self): + self.assertEqual(self.post({'speed_presets': [0.5, 3.5, 1.234]}).json()['speed_presets'], [0.5, 1.23, 3.5]) + self.assertEqual(self.post({'speed_presets': [1.0] * 5}).json()['speed_presets'], [1.0]) + + def test_invalid_values_change_nothing(self): + self.post({'skip_back': 15}) + bad = [ + ('skip_back', 7), ('skip_back', 0), ('skip_back', '10'), ('skip_back', 10.5), ('skip_back', True), + ('skip_forward', 90), ('smart_played', -1), ('smart_played', 3601), ('smart_played', 'x'), + ('speed_presets', []), ('speed_presets', [0.4]), ('speed_presets', [3.6]), ('speed_presets', ['1']), + ('speed_presets', 1.5), ('speed_presets', [0.5 + 0.25 * i for i in range(13)]), + ('keep_skipped', 1), ('delete_dequeue', None), ('mobile_downloads', 'false'), ('times_by_speed', 0), + ('mobile_streaming', 'sometimes'), ('mobile_streaming', None), ('enqueue_position', 'middle'), + ] + for name, value in bad: + with self.subTest(name=name, value=value): + r = self.post({'keep_skipped': False, name: value} if name != 'keep_skipped' else {name: value}) + self.assertEqual(r.status_code, 400) + self.assertIn(name, r.json()['error']) + self.assertEqual(r.json()['field'], name) + self.assertEqual(self.settings(), {**PLAYBACK_DEFAULTS, 'skip_back': 15}) + self.assertEqual(self.post([1]).status_code, 400) + self.assertEqual(self.post({'initial': [1]}).status_code, 400) + + def test_initial_sets_only_what_is_unset(self): + """An app hands over its own values once: they only fill what the account doesn't have yet.""" + self.post({'skip_forward': 45}) + r = self.post({'initial': {'skip_back': 10, 'skip_forward': 30, 'smart_played': 60, + 'keep_skipped': False}}) # keep_skipped isn't one that starts unset + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual({k: r.json()[k] for k in ('skip_back', 'skip_forward', 'smart_played', 'keep_skipped')}, + {'skip_back': 10, 'skip_forward': 45, 'smart_played': 60, 'keep_skipped': True}) + # A second device comes too late: nothing changes. + r = self.post({'initial': {'skip_back': 5, 'smart_played': 0, 'speed_presets': [1, 2]}}) + self.assertEqual({k: r.json()[k] for k in ('skip_back', 'smart_played', 'speed_presets')}, + {'skip_back': 10, 'smart_played': 60, 'speed_presets': [1.0, 2.0]}) + # Invalid initial values are refused like any other. + r = self.post({'initial': {'skip_back': 7}}) + self.assertEqual((r.status_code, r.json()['field']), (400, 'skip_back')) + # Initial and a change at once: the change wins. + PodcastSettings.objects.filter(user=self.user).update(smart_played=None) + r = self.post({'smart_played': 120, 'initial': {'smart_played': 30}}) + self.assertEqual(r.json()['smart_played'], 120) + + def test_login(self): + self.client.logout() + self.assertEqual(self.post({'skip_back': 10}).status_code, 401) + self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401) diff --git a/podcasts/views.py b/podcasts/views.py index e94602c..d075d4f 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -27,7 +27,7 @@ from . import feedext from . import transcripts as transcripts_mod from .models import ( EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, - PodcastSettings, counts_as_played, + MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, ) from .shownotes import extract_timestamps, sanitize_html @@ -725,7 +725,7 @@ def feed_list(request): FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', - 'notify', + 'notify', 'episode_sort', 'episode_filter', 'skip_silence', ) @@ -878,11 +878,34 @@ def _whole(value, low, high): def _speed(value): if value is None: return None - if not _number(value) or not 0.5 <= value <= 3.0: + if not _number(value) or not MIN_SPEED <= value <= MAX_SPEED: raise ValueError return float(value) +def _episode_sort(value): + if value in (None, ''): + return '' + if value not in PodcastFeed.EPISODE_SORTS: + raise ValueError + return value + + +def _episode_filter(value): + """A list of filter ids (each once, in the given order); [] = all episodes.""" + if value is None: + return [] + if not isinstance(value, list) or any(v not in PodcastFeed.EPISODE_FILTERS for v in value): + raise ValueError + return list(dict.fromkeys(value)) + + +def _optional_boolean(value): + if value is not None and not isinstance(value, bool): + raise ValueError + return value + + def _volume(value): if not _number(value) or float(value) not in PodcastFeed.VOLUMES: raise ValueError @@ -920,6 +943,9 @@ FEED_SETTING_PARSERS = { 'min_duration': lambda v: _whole(v, 0, 600), 'auto_download': _boolean, 'notify': _boolean, + 'episode_sort': _episode_sort, + 'episode_filter': _episode_filter, + 'skip_silence': _optional_boolean, } @@ -1557,8 +1583,7 @@ def podcast_state(request): 'feeds': _feeds_data(request.user), 'queue': _queue_data(request.user), 'inbox': _inbox_data(request.user, 0, limit, with_description=False), - 'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position, - 'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default}, + 'settings': _settings_data(s), 'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)}, 'server_time': timezone.now().isoformat(), }) @@ -1677,9 +1702,90 @@ def queue_reorder(request): return JsonResponse({'ok': True}) +def _choice(choices, what): + def parse(value): + if value not in choices: + raise LookupError(f'unknown {what}') + return value + return parse + + +def _skip_seconds(value): + if value is None: + return None + if not _number(value) or value != int(value) or int(value) not in PodcastSettings.SKIP_CHOICES: + raise ValueError + return int(value) + + +def _smart_played(value): + return None if value is None else _whole(value, 0, 3600) + + +def _speed_presets(value): + """Up to MAX_PRESETS speeds (MIN_SPEED to MAX_SPEED), rounded to two + places, each once, ascending; null = the apps' default list.""" + if value is None: + return None + if not isinstance(value, list) or not 1 <= len(value) <= PodcastSettings.MAX_PRESETS: + raise ValueError + out = set() + for v in value: + if not _number(v) or not math.isfinite(v) or not MIN_SPEED <= v <= MAX_SPEED: + raise ValueError + out.add(round(float(v), 2)) + return sorted(out) + + +# POST settings/: each field's parser; a LookupError's text is the error. +SETTING_PARSERS = { + 'enqueue_position': _choice(QUEUE_POSITIONS, 'enqueue_position'), + 'auto_queue_position': _choice(QUEUE_POSITIONS, 'auto_queue_position'), + 'keep_sorted': _choice(KEEP_SORTED, 'keep_sorted'), + 'auto_download_default': _boolean, + 'skip_back': _skip_seconds, + 'skip_forward': _skip_seconds, + 'smart_played': _smart_played, + 'speed_presets': _speed_presets, + 'keep_skipped': _boolean, + 'delete_dequeue': _boolean, + 'mobile_downloads': _boolean, + 'mobile_streaming': _choice({key for key, _ in PodcastSettings.STREAMING}, 'mobile_streaming'), + 'times_by_speed': _boolean, +} + + +def _settings_data(s): + return { + 'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position, + 'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default, + **{name: getattr(s, name) for name in PodcastSettings.PLAYBACK_FIELDS}, + } + + +def _parse_settings(body, names): + """{field: value} for those of `names` in body; raises (field, error text).""" + out = {} + for name in names: + if name not in body: + continue + try: + out[name] = SETTING_PARSERS[name](body[name]) + except LookupError as e: + raise ValueError(name, str(e)) + except (ValueError, TypeError, OverflowError): + raise ValueError(name, f'invalid {name}') + return out + + @csrf_exempt def podcast_settings(request): - """GET the user's podcast settings; POST changes some of them.""" + """GET the user's podcast settings; POST changes some of them (any of + SETTING_PARSERS; nothing changes if one value is invalid). + + POST {"initial": {...}} sets the playback settings that are still unset + (null) and leaves the others: how an app hands over its own values once, + the first time it finds the account without them.""" if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) s = PodcastSettings.for_user(request.user) @@ -1688,35 +1794,40 @@ def podcast_settings(request): body = json.loads(request.body) except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'invalid JSON'}, status=400) - fields = [] - for name in ('enqueue_position', 'auto_queue_position'): - if name in body: - if body[name] not in QUEUE_POSITIONS: - return JsonResponse({'error': f'unknown {name}'}, status=400) - setattr(s, name, body[name]) - fields.append(name) - if 'keep_sorted' in body: - if body['keep_sorted'] not in KEEP_SORTED: - return JsonResponse({'error': 'unknown keep_sorted'}, status=400) - s.keep_sorted = body['keep_sorted'] - fields.append('keep_sorted') - if 'auto_download_default' in body: - if not isinstance(body['auto_download_default'], bool): - return JsonResponse({'error': 'invalid auto_download_default'}, status=400) - s.auto_download_default = body['auto_download_default'] - fields.append('auto_download_default') - if fields: - s.save(update_fields=fields) - if 'keep_sorted' in fields: + if not isinstance(body, dict): + return JsonResponse({'error': 'invalid JSON'}, status=400) + initial = body.get('initial') or {} + if not isinstance(initial, dict): + return JsonResponse({'error': 'invalid initial', 'field': 'initial'}, status=400) + try: + changes = _parse_settings(body, SETTING_PARSERS) + first = _parse_settings(initial, PodcastSettings.UNSET_FIELDS) + except ValueError as e: + name, text = e.args + return JsonResponse({'error': text, 'field': name}, status=400) + if first: + # Only where nothing is set yet, decided by the database (two + # devices handing theirs over at once: the first one wins). + with transaction.atomic(): + s = PodcastSettings.objects.select_for_update().get(pk=s.pk) + fields = [name for name, value in first.items() if getattr(s, name) is None and value is not None] + for name in fields: + setattr(s, name, first[name]) + if fields: + s.save(update_fields=fields) + if changes: + for name, value in changes.items(): + setattr(s, name, value) + s.save(update_fields=list(changes)) + if 'keep_sorted' in changes: _keep_sorted(request.user, s.keep_sorted) # sorted right away elif request.method != 'GET': return JsonResponse({'error': 'method not allowed'}, status=405) return JsonResponse({ - 'enqueue_position': s.enqueue_position, - 'auto_queue_position': s.auto_queue_position, - 'keep_sorted': s.keep_sorted, - 'auto_download_default': s.auto_download_default, - 'positions':[{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS], + **_settings_data(s), + 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS], + 'skip_choices': list(PodcastSettings.SKIP_CHOICES), + 'speed_range': [MIN_SPEED, MAX_SPEED], }) diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index e9091b1..cb7bac2 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -65,7 +65,25 @@ const POSITIONS = ['back', 'front', 'after_current']; const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', - 'new_episodes', 'include_words', 'exclude_words', 'min_duration']; + 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify', + 'episode_sort', 'episode_filter', 'skip_silence']; + // The account's playback settings (diora-web PodcastSettings) and what each may be. + const SKIPS = [5, 10, 15, 20, 30, 45, 60]; + const MIN_SPEED = 0.5; + const MAX_SPEED = 3.5; + const ACCOUNT_SETTINGS = { + skip_back: v => v === null || SKIPS.indexOf(v) >= 0, + skip_forward: v => v === null || SKIPS.indexOf(v) >= 0, + smart_played: v => v === null || (v === Math.round(v) && v >= 0 && v <= 3600), + speed_presets: v => v === null || (Array.isArray(v) && v.length > 0 && v.length <= 12 && + v.every(x => typeof x === 'number' && x >= MIN_SPEED && x <= MAX_SPEED)), + keep_skipped: v => typeof v === 'boolean', + delete_dequeue: v => typeof v === 'boolean', + mobile_downloads: v => typeof v === 'boolean', + mobile_streaming: v => ['allow', 'ask', 'never'].indexOf(v) >= 0, + times_by_speed: v => typeof v === 'boolean', + auto_download_default: v => typeof v === 'boolean', + }; function empty() { return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; @@ -340,6 +358,14 @@ settings.keep_sorted = body.keep_sorted = changes.keep_sorted; if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once } + for (const name of Object.keys(ACCOUNT_SETTINGS)) { + if (!(name in changes)) continue; + let v = changes[name]; + if (name === 'speed_presets' && Array.isArray(v)) { + v = v.map(x => Math.round(x * 100) / 100).filter((x, i, a) => a.indexOf(x) === i).sort((a, b) => a - b); + } + if (ACCOUNT_SETTINGS[name](v)) settings[name] = body[name] = v; + } if (Object.keys(body).length) queueOp('/podcasts/settings/', body); }); } @@ -371,20 +397,29 @@ // ---- this device's playback settings (PlaybackPrefs.kt) -------------------------- const CHOICES = { - skip_back: [5, 10, 15, 30], - skip_forward: [10, 15, 30, 45, 60], - smart_played: [0, 15, 30, 60, 120, 300, 600], + skip_back: SKIPS, + skip_forward: SKIPS, + smart_played: [0, 15, 30, 45, 60, 120, 300, 600], }; const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30, sleep_last: 15, sleep_auto: false}, load('prefs', {})); + // Skip lengths and "counts as played": the account's where it has them, else this browser's. + function eff(name) { + const v = (state.settings || {})[name]; + return v != null ? v : prefs[name]; + } + const keepSkipped = () => (state.settings || {}).keep_skipped !== false; + function setPref(name, value) { if (name === 'speed') { - if (!(value >= 0.5 && value <= 3)) return; + if (!(value >= MIN_SPEED && value <= MAX_SPEED)) return; prefs.speed = value; } else if (CHOICES[name]) { if (CHOICES[name].indexOf(Math.round(value)) < 0) return; prefs[name] = Math.round(value); + // The account's setting, where the server has it: the same on every device. + if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); } } else if (name === 'remove_played') { prefs.remove_played = !!value; } else { @@ -395,8 +430,8 @@ // What the page shows as settings. No skip_silence / headset_next: the page leaves those out. function prefsJson() { - return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward, - remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}}; + return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'), + remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}}; } function speedFor(feedId) { @@ -519,7 +554,7 @@ // Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished. function almostEnded() { const dur = durMs(); - return dur > 0 && posMs() >= dur - Math.max(prefs.smart_played * 1000, 1000); + return dur > 0 && posMs() >= dur - Math.max(eff('smart_played') * 1000, 1000); } function nextEntry() { @@ -601,12 +636,15 @@ } // Move to another episode (onMediaItemTransition): the one we leave counts as - // finished when it ended (auto) or is left within its last seconds; else it keeps its place. - function goTo(e, auto, play) { + // finished when it ended (auto) or is left within its last seconds; one skipped + // to the next (⏭, media keys) counts as played when skipped ones aren't kept + // (the account's keep_skipped); else it keeps its place. + function goTo(e, auto, play, skipped) { const prev = curId(); let stop = false; if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (auto || almostEnded()) stop = finished(prev); + else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); } else saveNow(prev, true); } unload(); @@ -682,15 +720,16 @@ function skip(forward) { const pos = posMs(); - if (!forward) { seek(pos - prefs.skip_back * 1000); return; } - const to = pos + prefs.skip_forward * 1000; + if (!forward) { seek(pos - eff('skip_back') * 1000); return; } + const to = pos + eff('skip_forward') * 1000; const dur = durMs(); seek(dur > 0 ? Math.min(to, dur - 1000) : to); } + // To the next episode: the one left is skipped (see goTo). function next() { const n = nextEntry(); - if (n) goTo(n, false, wantPlay); + if (n) goTo(n, false, wantPlay, true); } function playEpisode(id) { diff --git a/static/js/sw.js b/static/js/sw.js index 9398b7e..c8f2fd2 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-v64'; +const CACHE = 'diora-v65'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 965897d..96eadb6 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 6b0855e 2026-10-02: web-native/static/native-podcast.js +// Copied from diora-android 304abf0 2026-10-02: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -36,7 +36,13 @@ {value: 'front', label: 'An den Anfang'}, {value: 'after_current', label: 'Nach der laufenden Folge'}, ]; + // The speed menu's choices while the account has no presets of its own (settings().speed_presets). const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2]; + const MIN_SPEED = 0.5; + const MAX_SPEED = 3.5; + const MAX_PRESETS = 12; + // Skip lengths back and forward (seconds), as AntennaPod offers them. + const SKIPS = [5, 10, 15, 20, 30, 45, 60]; function parse(s, fallback) { try { return JSON.parse(s); } catch (e) { return fallback; } @@ -61,6 +67,29 @@ const feeds = () => state.feeds || []; const settings = () => state.settings || {}; const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0); + // The account's speed presets (null there: the default list). + const presets = () => { + const p = settings().speed_presets; + return Array.isArray(p) && p.length ? p.slice().sort((a, b) => a - b) : SPEEDS; + }; + // Skipping to the next episode keeps the skipped one (AntennaPod's "keep skipped episodes"). + const keepSkipped = () => settings().keep_skipped !== false; + + // "Zeiten an das Tempo anpassen": lengths and remaining times as long as they take at the speed. + const bySpeed = () => !!settings().times_by_speed; + let prefsSeen = null; + let prefsAt = 0; + function speedOf(feedId) { + if (!prefsSeen || Date.now() - prefsAt > 2000) { prefsSeen = devicePrefs(); prefsAt = Date.now(); } + const f = feedId ? feeds().find(x => x.id === feedId) : null; + return f && f.playback_speed > 0 ? f.playback_speed : prefsSeen.speed > 0 ? prefsSeen.speed : 1; + } + // Seconds as shown: at the podcast's speed (or `speed`), when so set. + function shown(seconds, feedId, speed) { + if (!bySpeed()) return seconds; + const s = speed > 0 ? speed : speedOf(feedId); + return s > 0 ? seconds / s : seconds; + } // ---- small helpers --------------------------------------------------------------- @@ -256,16 +285,23 @@ {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')}, removePlayedItem(), + keepSkippedItem(), + deleteDequeueItem(), ]), - group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“', - 'headset_next' in prefs && 'Kopfhörer', 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [ - skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]), - skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]), + group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“', + 'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer', + 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [ + skipItem('skip_back', 'Zurückspringen', SKIPS), + skipItem('skip_forward', 'Vorspringen', SKIPS), + 'speed_presets' in s && {icon: '»', label: 'Tempo-Voreinstellungen', hint: presets().map(speedLabel).join(' · '), + action: () => presetsEditor()}, silenceItem(), playedItem(), + timesItem(), headsetItem(), notifyItem(), ]), + mobileGroup(), DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads}, DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [ // Account-wide (server): new subscriptions start with auto-download on or off. @@ -302,12 +338,17 @@ })))}; } + // Skip lengths and "counts as played" belong to the account where the server knows them + // (the host sends them there, PlaybackPrefs.set); else to this device. + const where = name => (name in settings() ? 'für alle Geräte' : 'auf diesem Gerät'); + function skipItem(name, label, choices) { if (!N.podcastSetPrefValue) return null; const now = devicePrefs()[name]; return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`, - action: () => openSheet(label, choices.map(c => ({ - icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c), + action: () => openSheet(`${label} (${where(name)})`, choices.map(c => ({ + icon: c === now ? '●' : '○', label: `${c} Sekunden`, active: c === now, + action: () => { N.podcastSetPrefValue(name, c); toast(`${label}: ${c} Sekunden`); }, })))}; } @@ -317,9 +358,116 @@ if (now == null) return null; const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende'); return {icon: '✓', label: 'Als gehört zählen', hint: label(now), - action: () => openSheet('Eine Folge zählt als gehört …', [0, 15, 30, 60, 120, 300, 600].map(v => ({ - icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v), - })))}; + action: () => openSheet(`Eine Folge zählt als gehört … (${where('smart_played')})`, + [0, 15, 30, 45, 60, 120, 300, 600].map(v => ({ + icon: v === now ? '●' : '○', label: label(v), active: v === now, + action: () => N.podcastSetPrefValue('smart_played', v), + })))}; + } + + // An account setting (server: POST /podcasts/settings/), through the host. + function setAccount(name, value, text) { + if (!N.podcastSettings) return; + N.podcastSettings(JSON.stringify({[name]: value})); + if (text) toast(text); + } + + function accountSwitch(name, label, onHint, offHint, def) { + const s = settings(); + if (!(name in s)) return null; + const on = s[name] == null ? def : !!s[name]; + return {icon: on ? '☑' : '☐', label, hint: on ? onHint : offHint, + action: () => setAccount(name, !on, `${label}: ${on ? 'aus' : 'an'}`)}; + } + + // AntennaPod's "keep skipped episodes": ⏭, "Folge überspringen", headset and car go on without marking. + function keepSkippedItem() { + return accountSwitch('keep_skipped', 'Übersprungene Folgen behalten', + 'an: wer zur nächsten springt, lässt die Folge ungehört in der Warteschlange', + 'aus: übersprungene Folgen zählen als gehört', true); + } + + // AntennaPod's "delete removes from queue". + function deleteDequeueItem() { + if (!DL) return null; + return accountSwitch('delete_dequeue', 'Löschen nimmt aus der Warteschlange', + 'an: wer einen Download löscht, nimmt die Folge auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true); + } + + // AntennaPod's "adjust media info to playback speed". + function timesItem() { + return accountSwitch('times_by_speed', 'Zeiten an das Tempo anpassen', + 'an: Längen und Restzeiten so, wie lange sie beim eingestellten Tempo dauern', 'aus: Längen wie bei 1×', false); + } + + // What may use mobile data: downloads by hand (account), automatic ones (this device), streaming (account). + const STREAMING = [ + {value: 'allow', label: 'Erlauben'}, + {value: 'ask', label: 'Nachfragen', hint: 'einmal pro Folge'}, + {value: 'never', label: 'Nie', hint: 'nur WLAN oder heruntergeladene Folgen'}, + ]; + + function mobileGroup() { + const s = settings(); + const items = () => { + const now = settings(); + const dls = DL ? parse(N.podcastDownloadSettings(), dlSettings) : {}; + const streaming = STREAMING.find(c => c.value === now.mobile_streaming) || STREAMING[0]; + return [ + DL && accountSwitch('mobile_downloads', 'Downloads per Hand über mobile Daten', + 'an: was du von Hand lädst, lädt auch unterwegs', 'aus: wartet aufs WLAN, wie automatische Downloads', true), + DL && {icon: dls.mobile ? '☑' : '☐', label: 'Automatische Downloads über mobile Daten', + hint: (dls.mobile ? 'an' : 'aus: nur im WLAN') + ' · dieses Gerät', action: () => dlSet({mobile: !dls.mobile})}, + N.podcastAllowStream && 'mobile_streaming' in now && {icon: '⇵', label: 'Streamen über mobile Daten', + hint: streaming.label + ' · Radio ist davon nicht betroffen', + action: () => openSheet('Folgen über mobile Daten streamen', STREAMING.map(c => ({ + icon: c.value === streaming.value ? '●' : '○', label: c.label, hint: c.hint || null, active: c.value === streaming.value, + action: () => setAccount('mobile_streaming', c.value, `Streamen über mobile Daten: ${c.label}`), + })))}, + ].filter(Boolean); + }; + if (!('mobile_downloads' in s || 'mobile_streaming' in s) || !items().length) return null; + return {icon: '📶', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())}; + } + + // ---- speed presets (the account's; AntennaPod's speed presets, up to 3.5×) ---- + + const roundSpeed = v => Math.round(v * 100) / 100; + + function savePresets(list) { + setAccount('speed_presets', list); + setTimeout(() => presetsEditor(list || SPEEDS), 60); + } + + function presetsEditor(list) { + const now = (list || presets()).slice().sort((a, b) => a - b); + const own = Array.isArray(settings().speed_presets) || !!list; + openSheet('Tempo-Voreinstellungen', now.map(v => ({ + icon: '✕', label: speedLabel(v), hint: now.length > 1 ? 'antippen zum Entfernen' : 'eine bleibt immer', + action: () => { if (now.length > 1) savePresets(now.filter(x => x !== v)); else toast('Eine Voreinstellung bleibt immer'); }, + })).concat([ + {icon: '+', label: 'Tempo hinzufügen', hint: `${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`, + action: () => addPreset(now)}, + own && {icon: '↺', label: 'Standardliste', hint: SPEEDS.map(speedLabel).join(' · '), action: () => savePresets(null)}, + ].filter(Boolean))); + } + + function addPreset(now) { + if (now.length >= MAX_PRESETS) { toast(`Höchstens ${MAX_PRESETS} Voreinstellungen`); return; } + const add = v => savePresets(now.concat(roundSpeed(v)).sort((a, b) => a - b)); + const choices = []; + for (let v = MIN_SPEED; v <= MAX_SPEED + 0.001; v += 0.25) { + if (!now.some(x => Math.abs(x - v) < 0.001)) choices.push(roundSpeed(v)); + } + openSheet('Tempo hinzufügen', choices.map(v => ({icon: '+', label: speedLabel(v), action: () => add(v)})) + .concat(typeof customPrompt === 'function' ? [{icon: '✎', label: 'Eigener Wert …', hint: 'z. B. 1,65', action: async () => { + const t = await customPrompt(`Tempo (${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}):`, ''); + if (t == null) return; + const v = roundSpeed(parseFloat(String(t).replace(',', '.').replace(/[×x]/g, ''))); + if (!(v >= MIN_SPEED && v <= MAX_SPEED)) { toast(`Bitte eine Zahl von ${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`); return; } + if (now.some(x => Math.abs(x - v) < 0.001)) { toast(`${speedLabel(v)} ist schon dabei`); return; } + add(v); + }}] : [])); } // A host without headset buttons or silence skipping (the browser) leaves them out of its prefs. @@ -335,17 +483,20 @@ // The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's. const NOTIFY = [ - {key: 'next', label: 'Folge überspringen', hint: 'zählt als gehört, die nächste spielt'}, - {key: 'chapter', label: 'Nächstes Kapitel', hint: 'wenn die Folge Kapitel hat'}, - {key: 'speed', label: 'Tempo', hint: '1× · 1,2× · 1,5× · 1,75× · 2×'}, - {key: 'sleep', label: 'Sleep-Timer', hint: '15 Minuten an / aus'}, + {key: 'next', label: 'Folge überspringen', + hint: () => (keepSkipped() ? 'die nächste spielt, diese bleibt ungehört eingereiht' : 'zählt als gehört, die nächste spielt')}, + {key: 'chapter', label: 'Nächstes Kapitel', hint: () => 'wenn die Folge Kapitel hat'}, + // The account's presets; without any, the app's own list. + {key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length + ? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')}, + {key: 'sleep', label: 'Sleep-Timer', hint: () => '15 Minuten an / aus'}, ]; function notifyItem() { if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null; const now = devicePrefs().notify_buttons; const names = keys => keys.map(k => (NOTIFY.find(n => n.key === k) || {}).label).filter(Boolean).join(', ') || 'keine'; const sheet = chosen => openSheet('Zwei Knöpfe in der Benachrichtigung', NOTIFY.map(n => ({ - icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint, + icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint(), // A third one replaces the one chosen first. action: () => { const next = chosen.includes(n.key) ? chosen.filter(k => k !== n.key) : chosen.concat(n.key).slice(-2); @@ -749,7 +900,7 @@ if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null; const box = el('div', 'nui-pod-offer'); const s = parse(N.podcastDownloadSettings(), {}); - const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? s.count || 0 : 0); + const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? (s.count === 0 ? queue().length : s.count || 0) : 0); const raise = s.limit > 0 && needed > s.limit ? ` „Höchstens behalten“ geht dafür von ${s.limit} auf ${[5, 10, 25, 50, 100, 500].find(n => n >= needed) || 'unbegrenzt'}.` : ''; box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` + @@ -791,7 +942,7 @@ const meta = []; // A played episode, or a place past the feed's (often wrong) length: just the length. const started = e.pos > 30 && !e.played && e.pos < e.duration - 30; - if (e.duration) meta.push(started ? minutes(e.duration - e.pos) + ' übrig' : minutes(e.duration)); + if (e.duration) meta.push(started ? minutes(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId))); if (opts.showDate && e.date) meta.push(day(e.date)); if (opts.showFeed && e.feed) meta.push(e.feed); main.append(el('div', 'nui-pod-meta', meta.join(' · '))); @@ -829,7 +980,7 @@ empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.'); return; } - const total = items.reduce((n, e) => n + Math.max(0, (e.duration || 0) - (e.pos || 0)), 0); + const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0); if (selected) { root.append(selectionBar(items, [ {label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))}, @@ -1166,12 +1317,28 @@ root.append(list); } - // A podcast's list: its filters and order, remembered per podcast on this device. + // A podcast's list: its filters and order, per podcast. On the server (episode_sort, + // episode_filter: the same on every device) where it knows them; else on this device. + const serverLists = live => !!live && 'episode_sort' in live && !!N.podcastFeedSettings; + function feedListPrefs(id) { + const live = feeds().find(x => x.id === id); + if (serverLists(live)) { + return {sort: live.episode_sort || 'new', filters: Array.isArray(live.episode_filter) ? live.episode_filter : []}; + } try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; } } function saveFeedListPrefs(id, prefs) { + const live = feeds().find(x => x.id === id); + if (serverLists(live)) { + const sort = prefs.sort === 'new' ? '' : prefs.sort; + // Shows at once; the host sends it on. + live.episode_sort = sort; + live.episode_filter = prefs.filters.slice(); + N.podcastFeedSettings(id, JSON.stringify({episode_sort: sort, episode_filter: prefs.filters})); + return; + } try { const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}'); if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs; @@ -1179,6 +1346,26 @@ } catch (e) {} } + // Once the server keeps the lists: what this device remembered goes there (where the + // server has nothing set for that podcast yet), then the device forgets it. + function moveFeedListsToServer() { + if (!N.podcastFeedSettings) return; + let all = null; + try { all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || 'null'); } catch (e) { all = null; } + if (!all) return; + const list = feeds(); + if (!list.length || !list.some(f => 'episode_sort' in f)) return; + try { localStorage.removeItem('nui_pod_feedlists'); } catch (e) { return; } + for (const id of Object.keys(all)) { + const live = list.find(f => String(f.id) === id); + const p = all[id] || {}; + if (!serverLists(live) || live.episode_sort || (live.episode_filter || []).length) continue; + const filters = (Array.isArray(p.filters) ? p.filters : []).filter(x => FILTERS.some(y => y.id === x && !y.all)); + const sort = EP_SORTS.some(s => s.id === p.sort && !s.all) ? p.sort : 'new'; + if (filters.length || sort !== 'new') saveFeedListPrefs(live.id, {filters, sort}); + } + } + // ---- a podcast's info page: description, website, feed address, numbers ---------------- // In the shownotes' sheet (closeNotes closes it, also with the back button). @@ -1295,14 +1482,33 @@ }))); row.append( chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`, - SPEEDS.map(v => ({icon: own.speed === v ? '●' : '○', label: speedLabel(v), action: () => set('speed', v)})) - .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))), + speedChoices(own.speed).map(v => ({icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v), + action: () => set('speed', v)})) + .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]) + .concat(presetsEditItem()))), chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')), chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')), chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`, [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']] .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))), ); + // Skip silence for this podcast (server), where this player can skip silence at all. + if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) { + const mine = live.skip_silence; + const device = !!devicePrefs().skip_silence; + const b = el('button', 'nui-pod-chip' + (mine != null ? ' on' : '')); + b.type = 'button'; + b.append(el('span', null, 'Stille'), el('strong', null, mine === true ? 'überspringen' : mine === false ? 'nie überspringen' + : 'wie eingestellt')); + const save = v => { N.podcastFeedSettings(f.id, JSON.stringify({skip_silence: v})); setTimeout(render, 50); }; + b.addEventListener('click', () => openSheet(`Stille in „${plain(f.title)}“ …`, [ + {icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)', + action: () => save(null)}, + {icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', action: () => save(true)}, + {icon: mine === false ? '●' : '○', label: 'Nie überspringen', action: () => save(false)}, + ])); + row.append(b); + } return row; } @@ -2081,7 +2287,7 @@ sk.setAttribute('aria-label', sk.title); } mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …' - : (player.feed || '') + (player.dur ? ' · ' + clock((player.dur - player.pos) / 1000) + ' übrig' : ''); + : (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = player.playing ? '❚❚' : '▶'; t.title = player.playing ? 'Pause' : 'Abspielen'; @@ -2361,7 +2567,7 @@ range.id = 'nui-pod-seek'; range.addEventListener('input', () => { seeking = true; - sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value); + sheet.querySelector('.nui-pod-time-pos').textContent = clock(shown(range.value, 0, player.speed || 1)); }); range.addEventListener('change', () => { N.podcastSeek(range.value * 1000); @@ -2388,7 +2594,8 @@ const b = sheet && sheet.querySelector('.nui-pod-fav'); if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); } }), 'nui-pod-fav'), - button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'), + button('⏭', keepSkipped() ? 'Nächste Folge (diese bleibt in der Warteschlange)' : 'Folge abschließen, nächste spielen', + finishAndNext, 'nui-pod-next'), N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'), button('≡', 'Warteschlange', showQueue), ].filter(Boolean)); @@ -2506,11 +2713,13 @@ const dur = Math.round((player.dur || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000); range.max = dur || 1; + // At the speed, when so set: the place and what's left as long as they take. + const at = s => shown(s, 0, player.speed || 1); if (!seeking) { range.value = pos; - sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos); + sheet.querySelector('.nui-pod-time-pos').textContent = clock(at(pos)); } - sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : ''; + sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(at(dur - pos)) : ''; const p = sheet.querySelector('.nui-pod-play'); p.textContent = player.playing ? '❚❚' : '▶'; p.title = player.playing ? 'Pause' : 'Abspielen'; @@ -2530,20 +2739,33 @@ } // Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast. + // The presets, and the speed now if it isn't one of them (a podcast's own 3,5× …). + function speedChoices(now) { + const list = presets().slice(); + if (now > 0 && !list.some(s => Math.abs(s - now) < 0.01)) list.push(now); + return list.sort((a, b) => a - b); + } + + function presetsEditItem() { + return 'speed_presets' in settings() ? [{icon: '✎', label: 'Voreinstellungen bearbeiten', hint: 'hinzufügen, entfernen', + action: () => presetsEditor()}] : []; + } + function speedMenu() { const own = !!player.podcastSpeed; const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts', - SPEEDS.map(s => ({ + speedChoices(own === forPodcast ? player.speed || 1 : 0).map(s => ({ icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○', label: speedLabel(s), action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)), - })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : [])); + })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []) + .concat(presetsEditItem())); if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; } openSheet('Geschwindigkeit', [ {icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)}, {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene', action: () => pick(true)}, - ]); + ].concat(presetsEditItem())); } // ---- sleep timer (runs in the app, also with the screen off) ---- @@ -2584,11 +2806,18 @@ openSheet('Sleep-Timer', items); } + // ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept + // unplayed in the queue, or played. With nothing after it, it only counts as played + // when skipped episodes aren't kept. function finishAndNext() { const id = currentId(); if (!id) return; + const ids = queue().map(q => q.episode__id); + const at = ids.indexOf(id); + const last = at < 0 ? !ids.length : at === ids.length - 1; N.podcastNext(); - N.podcastMarkPlayed(id, true); + if (last && !keepSkipped()) N.podcastMarkPlayed(id, true); + else if (last) toast('Das ist die letzte Folge der Warteschlange'); } function showQueue() { @@ -2767,7 +2996,9 @@ let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; // All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {}; - const DL_COUNTS = [1, 2, 3, 5, 10]; + // How many of the queue's first episodes load by themselves; 0 = all of them. + const DL_COUNTS = [1, 2, 3, 5, 10, 0]; + const dlCountLabel = n => (n === 0 ? 'Alle' : n === 1 ? '1 Folge' : n + ' Folgen'); const DL_PARALLEL = [1, 2, 3, 4]; // Why a download waits (DownloadPlan.waitReason). const DL_REASONS = { @@ -2797,7 +3028,9 @@ function dlDelete(e, running) { N.podcastDeleteDownload(e.id); - toast(running ? 'Download abgebrochen' : 'Download gelöscht'); + // "Löschen nimmt aus der Warteschlange" (account): the app takes it out too. + const out = !running && settings().delete_dequeue !== false && 'delete_dequeue' in settings() && inQueue(e.id); + toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht'); } function dlAppend(row, e) { @@ -2864,9 +3097,11 @@ const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0); return [ {icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen', - hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})}, - s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen', - DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n, + hint: s.auto ? (s.count === 0 ? 'die ganze Warteschlange' : `die ersten ${s.count} Folgen der Warteschlange`) : 'aus', + action: () => dlSet({auto: !s.auto})}, + s.auto && {icon: '#', label: 'Wie viele Folgen', hint: dlCountLabel(s.count), action: () => openSheet('Automatisch herunterladen', + DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: dlCountLabel(n), hint: n === 0 ? 'die ganze Warteschlange' : null, + active: s.count === n, action: () => dlSet({count: n})})))}, s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN', action: () => dlSet({mobile: !s.mobile})}, @@ -3127,9 +3362,19 @@ window.__dioraPodcasts = function (json) { state = typeof json === 'string' ? parse(json, state) : json; + moveFeedListsToServer(); render(); }; + // The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream). + window.__dioraAskStream = function (id, title) { + if (!N.podcastAllowStream) return; + openSheet('Über mobile Daten streamen?', [ + {icon: '▶', label: 'Ja, diese Folge streamen', hint: plain(title) || null, action: () => N.podcastAllowStream(id)}, + {icon: '✕', label: 'Nein', hint: 'im WLAN oder heruntergeladen hören', action: () => {}}, + ]); + }; + // A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“). window.__dioraOpenFeed = function (feedId) { if (typeof showTab !== 'function') return; @@ -3179,6 +3424,7 @@ build(); buildMini(); player = parse(N.playerState(), {kind: null}); + moveFeedListsToServer(); render(); renderMini();