Podcast-Routine aus AntennaPod: Abspiel-Einstellungen fürs Konto, Listen und Stille pro Podcast; Oberfläche aus diora-android 304abf0, sw v65
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m39s

- 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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 18:46:55 +02:00
parent 3577fca9ba
commit 54fbd58293
9 changed files with 821 additions and 90 deletions

View file

@ -25,7 +25,7 @@ from urllib.parse import urlsplit
from django.db import transaction from django.db import transaction
from django.db.models import Q 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 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 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 MAX_FILESIZE = 4_000_000_000
SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words', 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 # Columns read from the backup, with the value to assume if an older
# AntennaPod didn't have the column yet. # AntennaPod didn't have the column yet.
@ -52,7 +63,7 @@ FEED_COLUMNS = {
'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '', 'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '',
'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1, '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, '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 # 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): def feed_settings(ap, global_auto_download=False):
"""diora settings for one AntennaPod feed row: only those AntennaPod sets """diora settings for one AntennaPod feed row: only those AntennaPod sets
to something other than its default. Returns (settings, not_kept_updated).""" to something other than its default. Returns (settings, not_kept_updated)."""
s = {} s = {}
speed = ap['feed_playback_speed'] speed = ap['feed_playback_speed']
if speed is not None and speed > 0: # -1 = the global 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')): for src, dst in (('feed_skip_intro', 'skip_intro'), ('feed_skip_ending', 'skip_outro')):
seconds = int(ap[src] or 0) seconds = int(ap[src] or 0)
if seconds > 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). # (0, AntennaPod's default, leaves diora's setting alone).
if ap['episode_notification']: if ap['episode_notification']:
s['notify'] = True 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 return s, not_kept_updated
@ -230,6 +274,8 @@ class Stats:
skipped_feeds: list = field(default_factory=list) # (title, url) skipped_feeds: list = field(default_factory=list) # (title, url)
settings: dict = field(default_factory=lambda: {name: 0 for name in SETTING_FIELDS}) settings: dict = field(default_factory=lambda: {name: 0 for name in SETTING_FIELDS})
not_kept_updated: int = 0 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_on: int = 0
auto_download_explicit: int = 0 auto_download_explicit: int = 0
auto_download_global: int = 0 auto_download_global: int = 0
@ -414,6 +460,8 @@ class AntennaPodImport:
settings, not_kept_updated = feed_settings(ap, self.global_auto_download) settings, not_kept_updated = feed_settings(ap, self.global_auto_download)
st.not_kept_updated += not_kept_updated 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_explicit += ap['auto_download'] == AUTO_DOWNLOAD_ON
st.auto_download_global += ap['auto_download'] == AUTO_DOWNLOAD_GLOBAL st.auto_download_global += ap['auto_download'] == AUTO_DOWNLOAD_GLOBAL
for name, value in settings.items(): 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', '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', 'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration',
'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications', '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', {len(st.removed_titles)} only in diora unsubscribed (--replace)' if st.removed_titles else ''),
f'Settings: changed: {settings}', f'Settings: changed: {settings}',
f' {st.not_kept_updated} feeds not kept updated in AntennaPod -> new episodes "ignore"', 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'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"{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)}' f'by --global-auto-download); default for new subscriptions: {_on(st.auto_download_default)}'

View file

@ -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),
),
]

View file

@ -1,6 +1,11 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models 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): class PodcastFeed(models.Model):
# What happens to a new episode of this feed (one that passed the filter). # 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 # Devices tell about this feed's new episodes (a notification, like
# AntennaPod's per-podcast "episode notifications"). # AntennaPod's per-podcast "episode notifications").
notify = models.BooleanField(default=False) 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: class Meta:
unique_together = ('user', 'rss_url') unique_together = ('user', 'rss_url')
@ -244,6 +260,40 @@ class PodcastSettings(models.Model):
# to download them again, then clears the list. # to download them again, then clears the list.
import_downloads = models.JSONField(default=list, blank=True) 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 @classmethod
def for_user(cls, user): def for_user(cls, user):
obj, _ = cls.objects.get_or_create(user=user) obj, _ = cls.objects.get_or_create(user=user)

View file

@ -32,7 +32,7 @@ class Backup:
feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0, feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0,
feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption 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, 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, CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER,
read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER, read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER,
item_identifier TEXT, image_url TEXT); 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)} feeds = {f.rss_url.rsplit('/', 1)[1]: f for f in PodcastFeed.objects.filter(user=self.user)}
fast, slow = feeds['fast'], feeds['slow'] fast, slow = feeds['fast'], feeds['slow']
self.assertEqual((fast.playback_speed, fast.volume, fast.min_duration, fast.new_episodes, fast.auto_queue), 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), self.assertEqual((slow.playback_speed, slow.volume, slow.new_episodes, slow.auto_queue),
(0.5, 1.5, 'inbox', False)) (0.5, 1.5, 'inbox', False))
self.assertEqual((feeds['loud'].volume, feeds['quiet'].volume), (2.0, 0.5)) 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}) 'https://example.org/on': True, 'https://example.org/off': False})
self.assertIn('notifications 1', out) 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): def test_backup_without_episode_notification_column(self):
self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification') self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification')
self.ap.feed('https://example.org/old') self.ap.feed('https://example.org/old')

View file

@ -12,6 +12,13 @@ from .views import FeedFetch, _refresh_feed
_parse = feedparser.parse _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): def _ok(rss, **kw):
"""What _fetch_feed returns for a 200 with this RSS.""" """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). # Shownotes stay out of the one-request state (they can be large).
self.assertNotIn('description', data['inbox'][0]) self.assertNotIn('description', data['inbox'][0])
self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front', 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 = { FEED_DEFAULTS = {
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', '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, '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, '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, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
'auto_queue': False, 'auto_download': False, 'notify': False, 'auto_queue': False, 'auto_download': False, 'notify': False,
'episode_sort': '', 'episode_filter': [], 'skip_silence': None,
}) })
self.feed.refresh_from_db() self.feed.refresh_from_db()
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), 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): 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): for v in (0.2, 0.5, 1.0, 1.5, 2.0, 2.5):
self.assertEqual(self.post({'volume': v}).json()['volume'], v) 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) 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): def test_invalid_values_name_the_field_and_change_nothing(self):
bad = [ 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_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), ('skip_outro', 601), ('volume', 0.7), ('volume', '1.0'), ('volume', None),
('new_episodes', 'later'), ('new_episodes', 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.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
self.client.logout() self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401) 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)

View file

@ -27,7 +27,7 @@ from . import feedext
from . import transcripts as transcripts_mod from . import transcripts as transcripts_mod
from .models import ( from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, 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 from .shownotes import extract_timestamps, sanitize_html
@ -725,7 +725,7 @@ def feed_list(request):
FEED_SETTING_FIELDS = ( FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', '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): def _speed(value):
if value is None: if value is None:
return 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 raise ValueError
return float(value) 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): def _volume(value):
if not _number(value) or float(value) not in PodcastFeed.VOLUMES: if not _number(value) or float(value) not in PodcastFeed.VOLUMES:
raise ValueError raise ValueError
@ -920,6 +943,9 @@ FEED_SETTING_PARSERS = {
'min_duration': lambda v: _whole(v, 0, 600), 'min_duration': lambda v: _whole(v, 0, 600),
'auto_download': _boolean, 'auto_download': _boolean,
'notify': _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), 'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user), 'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False), 'inbox': _inbox_data(request.user, 0, limit, with_description=False),
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position, 'settings': _settings_data(s),
'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default},
'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)}, 'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
'server_time': timezone.now().isoformat(), 'server_time': timezone.now().isoformat(),
}) })
@ -1677,9 +1702,90 @@ def queue_reorder(request):
return JsonResponse({'ok': True}) 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 @csrf_exempt
def podcast_settings(request): 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: if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401) return JsonResponse({'error': 'authentication required'}, status=401)
s = PodcastSettings.for_user(request.user) s = PodcastSettings.for_user(request.user)
@ -1688,35 +1794,40 @@ def podcast_settings(request):
body = json.loads(request.body) body = json.loads(request.body)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400) return JsonResponse({'error': 'invalid JSON'}, status=400)
fields = [] if not isinstance(body, dict):
for name in ('enqueue_position', 'auto_queue_position'): return JsonResponse({'error': 'invalid JSON'}, status=400)
if name in body: initial = body.get('initial') or {}
if body[name] not in QUEUE_POSITIONS: if not isinstance(initial, dict):
return JsonResponse({'error': f'unknown {name}'}, status=400) return JsonResponse({'error': 'invalid initial', 'field': 'initial'}, status=400)
setattr(s, name, body[name]) try:
fields.append(name) changes = _parse_settings(body, SETTING_PARSERS)
if 'keep_sorted' in body: first = _parse_settings(initial, PodcastSettings.UNSET_FIELDS)
if body['keep_sorted'] not in KEEP_SORTED: except ValueError as e:
return JsonResponse({'error': 'unknown keep_sorted'}, status=400) name, text = e.args
s.keep_sorted = body['keep_sorted'] return JsonResponse({'error': text, 'field': name}, status=400)
fields.append('keep_sorted') if first:
if 'auto_download_default' in body: # Only where nothing is set yet, decided by the database (two
if not isinstance(body['auto_download_default'], bool): # devices handing theirs over at once: the first one wins).
return JsonResponse({'error': 'invalid auto_download_default'}, status=400) with transaction.atomic():
s.auto_download_default = body['auto_download_default'] s = PodcastSettings.objects.select_for_update().get(pk=s.pk)
fields.append('auto_download_default') 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: if fields:
s.save(update_fields=fields) s.save(update_fields=fields)
if 'keep_sorted' in 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 _keep_sorted(request.user, s.keep_sorted) # sorted right away
elif request.method != 'GET': elif request.method != 'GET':
return JsonResponse({'error': 'method not allowed'}, status=405) return JsonResponse({'error': 'method not allowed'}, status=405)
return JsonResponse({ return JsonResponse({
'enqueue_position': s.enqueue_position, **_settings_data(s),
'auto_queue_position': s.auto_queue_position, 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
'keep_sorted': s.keep_sorted, 'skip_choices': list(PodcastSettings.SKIP_CHOICES),
'auto_download_default': s.auto_download_default, 'speed_range': [MIN_SPEED, MAX_SPEED],
'positions':[{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
}) })

View file

@ -65,7 +65,25 @@
const POSITIONS = ['back', 'front', 'after_current']; const POSITIONS = ['back', 'front', 'after_current'];
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 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() { function empty() {
return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; 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; settings.keep_sorted = body.keep_sorted = changes.keep_sorted;
if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once 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); if (Object.keys(body).length) queueOp('/podcasts/settings/', body);
}); });
} }
@ -371,20 +397,29 @@
// ---- this device's playback settings (PlaybackPrefs.kt) -------------------------- // ---- this device's playback settings (PlaybackPrefs.kt) --------------------------
const CHOICES = { const CHOICES = {
skip_back: [5, 10, 15, 30], skip_back: SKIPS,
skip_forward: [10, 15, 30, 45, 60], skip_forward: SKIPS,
smart_played: [0, 15, 30, 60, 120, 300, 600], 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, 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', {})); 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) { function setPref(name, value) {
if (name === 'speed') { if (name === 'speed') {
if (!(value >= 0.5 && value <= 3)) return; if (!(value >= MIN_SPEED && value <= MAX_SPEED)) return;
prefs.speed = value; prefs.speed = value;
} else if (CHOICES[name]) { } else if (CHOICES[name]) {
if (CHOICES[name].indexOf(Math.round(value)) < 0) return; if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
prefs[name] = Math.round(value); 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') { } else if (name === 'remove_played') {
prefs.remove_played = !!value; prefs.remove_played = !!value;
} else { } else {
@ -395,8 +430,8 @@
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out. // What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() { function prefsJson() {
return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward, return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}}; remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}};
} }
function speedFor(feedId) { function speedFor(feedId) {
@ -519,7 +554,7 @@
// Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished. // Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished.
function almostEnded() { function almostEnded() {
const dur = durMs(); 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() { function nextEntry() {
@ -601,12 +636,15 @@
} }
// Move to another episode (onMediaItemTransition): the one we leave counts as // 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. // finished when it ended (auto) or is left within its last seconds; one skipped
function goTo(e, auto, play) { // 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(); const prev = curId();
let stop = false; let stop = false;
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev); if (auto || almostEnded()) stop = finished(prev);
else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
else saveNow(prev, true); else saveNow(prev, true);
} }
unload(); unload();
@ -682,15 +720,16 @@
function skip(forward) { function skip(forward) {
const pos = posMs(); const pos = posMs();
if (!forward) { seek(pos - prefs.skip_back * 1000); return; } if (!forward) { seek(pos - eff('skip_back') * 1000); return; }
const to = pos + prefs.skip_forward * 1000; const to = pos + eff('skip_forward') * 1000;
const dur = durMs(); const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to); seek(dur > 0 ? Math.min(to, dur - 1000) : to);
} }
// To the next episode: the one left is skipped (see goTo).
function next() { function next() {
const n = nextEntry(); const n = nextEntry();
if (n) goTo(n, false, wantPlay); if (n) goTo(n, false, wantPlay, true);
} }
function playEpisode(id) { function playEpisode(id) {

View file

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

View file

@ -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 // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js. // and copy again. The browser host is static/js/podcasts-web.js.
@ -36,7 +36,13 @@
{value: 'front', label: 'An den Anfang'}, {value: 'front', label: 'An den Anfang'},
{value: 'after_current', label: 'Nach der laufenden Folge'}, {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 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) { function parse(s, fallback) {
try { return JSON.parse(s); } catch (e) { return fallback; } try { return JSON.parse(s); } catch (e) { return fallback; }
@ -61,6 +67,29 @@
const feeds = () => state.feeds || []; const feeds = () => state.feeds || [];
const settings = () => state.settings || {}; const settings = () => state.settings || {};
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0); 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 --------------------------------------------------------------- // ---- small helpers ---------------------------------------------------------------
@ -256,16 +285,23 @@
{icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position),
action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')}, action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')},
removePlayedItem(), removePlayedItem(),
keepSkippedItem(),
deleteDequeueItem(),
]), ]),
group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“', group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“',
'headset_next' in prefs && 'Kopfhörer', 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [ 'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer',
skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]), 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [
skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]), 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(), silenceItem(),
playedItem(), playedItem(),
timesItem(),
headsetItem(), headsetItem(),
notifyItem(), notifyItem(),
]), ]),
mobileGroup(),
DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads}, DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads},
DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [ DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [
// Account-wide (server): new subscriptions start with auto-download on or off. // 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) { function skipItem(name, label, choices) {
if (!N.podcastSetPrefValue) return null; if (!N.podcastSetPrefValue) return null;
const now = devicePrefs()[name]; const now = devicePrefs()[name];
return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`, return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`,
action: () => openSheet(label, choices.map(c => ({ action: () => openSheet(`${label} (${where(name)})`, choices.map(c => ({
icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c), icon: c === now ? '●' : '○', label: `${c} Sekunden`, active: c === now,
action: () => { N.podcastSetPrefValue(name, c); toast(`${label}: ${c} Sekunden`); },
})))}; })))};
} }
@ -317,11 +358,118 @@
if (now == null) return null; if (now == null) return null;
const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende'); 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), 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 => ({ action: () => openSheet(`Eine Folge zählt als gehört … (${where('smart_played')})`,
icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v), [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. // A host without headset buttons or silence skipping (the browser) leaves them out of its prefs.
function headsetItem() { function headsetItem() {
if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null; if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null;
@ -335,17 +483,20 @@
// The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's. // The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's.
const NOTIFY = [ const NOTIFY = [
{key: 'next', label: 'Folge überspringen', hint: 'zählt als gehört, die nächste spielt'}, {key: 'next', label: 'Folge überspringen',
{key: 'chapter', label: 'Nächstes Kapitel', hint: 'wenn die Folge Kapitel hat'}, hint: () => (keepSkipped() ? 'die nächste spielt, diese bleibt ungehört eingereiht' : 'zählt als gehört, die nächste spielt')},
{key: 'speed', label: 'Tempo', hint: '1× · 1,2× · 1,5× · 1,75× · 2×'}, {key: 'chapter', label: 'Nächstes Kapitel', hint: () => 'wenn die Folge Kapitel hat'},
{key: 'sleep', label: 'Sleep-Timer', hint: '15 Minuten an / aus'}, // 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() { function notifyItem() {
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null; if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
const now = devicePrefs().notify_buttons; const now = devicePrefs().notify_buttons;
const names = keys => keys.map(k => (NOTIFY.find(n => n.key === k) || {}).label).filter(Boolean).join(', ') || 'keine'; 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 => ({ 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. // A third one replaces the one chosen first.
action: () => { action: () => {
const next = chosen.includes(n.key) ? chosen.filter(k => k !== n.key) : chosen.concat(n.key).slice(-2); 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; if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null;
const box = el('div', 'nui-pod-offer'); const box = el('div', 'nui-pod-offer');
const s = parse(N.podcastDownloadSettings(), {}); 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 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'}.` : ''; ? ` „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` + box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` +
@ -791,7 +942,7 @@
const meta = []; const meta = [];
// A played episode, or a place past the feed's (often wrong) length: just the length. // 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; 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.showDate && e.date) meta.push(day(e.date));
if (opts.showFeed && e.feed) meta.push(e.feed); if (opts.showFeed && e.feed) meta.push(e.feed);
main.append(el('div', 'nui-pod-meta', meta.join(' · '))); 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.'); empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
return; 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) { if (selected) {
root.append(selectionBar(items, [ root.append(selectionBar(items, [
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))}, {label: 'Nach 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); 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) { 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 {}; } try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; }
} }
function saveFeedListPrefs(id, prefs) { 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 { try {
const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}'); const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}');
if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs; if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs;
@ -1179,6 +1346,26 @@
} catch (e) {} } 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 ---------------- // ---- a podcast's info page: description, website, feed address, numbers ----------------
// In the shownotes' sheet (closeNotes closes it, also with the back button). // In the shownotes' sheet (closeNotes closes it, also with the back button).
@ -1295,14 +1482,33 @@
}))); })));
row.append( row.append(
chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`, 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)})) speedChoices(own.speed).map(v => ({icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v),
.concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))), 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('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('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)}“`, 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)']] [[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)})))), .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; return row;
} }
@ -2081,7 +2287,7 @@
sk.setAttribute('aria-label', sk.title); sk.setAttribute('aria-label', sk.title);
} }
mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …' 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'); const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶'; t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen'; t.title = player.playing ? 'Pause' : 'Abspielen';
@ -2361,7 +2567,7 @@
range.id = 'nui-pod-seek'; range.id = 'nui-pod-seek';
range.addEventListener('input', () => { range.addEventListener('input', () => {
seeking = true; 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', () => { range.addEventListener('change', () => {
N.podcastSeek(range.value * 1000); N.podcastSeek(range.value * 1000);
@ -2388,7 +2594,8 @@
const b = sheet && sheet.querySelector('.nui-pod-fav'); const b = sheet && sheet.querySelector('.nui-pod-fav');
if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); } if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); }
}), 'nui-pod-fav'), }), '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'), N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'),
button('≡', 'Warteschlange', showQueue), button('≡', 'Warteschlange', showQueue),
].filter(Boolean)); ].filter(Boolean));
@ -2506,11 +2713,13 @@
const dur = Math.round((player.dur || 0) / 1000); const dur = Math.round((player.dur || 0) / 1000);
const pos = Math.round((player.pos || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000);
range.max = dur || 1; 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) { if (!seeking) {
range.value = pos; 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'); const p = sheet.querySelector('.nui-pod-play');
p.textContent = player.playing ? '❚❚' : '▶'; p.textContent = player.playing ? '❚❚' : '▶';
p.title = player.playing ? 'Pause' : 'Abspielen'; p.title = player.playing ? 'Pause' : 'Abspielen';
@ -2530,20 +2739,33 @@
} }
// Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast. // 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() { function speedMenu() {
const own = !!player.podcastSpeed; const own = !!player.podcastSpeed;
const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts', 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 ? '●' : '○', icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○',
label: speedLabel(s), label: speedLabel(s),
action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(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; } if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; }
openSheet('Geschwindigkeit', [ openSheet('Geschwindigkeit', [
{icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)}, {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', {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene',
action: () => pick(true)}, action: () => pick(true)},
]); ].concat(presetsEditItem()));
} }
// ---- sleep timer (runs in the app, also with the screen off) ---- // ---- sleep timer (runs in the app, also with the screen off) ----
@ -2584,11 +2806,18 @@
openSheet('Sleep-Timer', items); 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() { function finishAndNext() {
const id = currentId(); const id = currentId();
if (!id) return; 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.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() { function showQueue() {
@ -2767,7 +2996,9 @@
let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {};
// All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. // 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(), {}) : {}; 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]; const DL_PARALLEL = [1, 2, 3, 4];
// Why a download waits (DownloadPlan.waitReason). // Why a download waits (DownloadPlan.waitReason).
const DL_REASONS = { const DL_REASONS = {
@ -2797,7 +3028,9 @@
function dlDelete(e, running) { function dlDelete(e, running) {
N.podcastDeleteDownload(e.id); 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) { function dlAppend(row, e) {
@ -2864,9 +3097,11 @@
const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0); const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0);
return [ return [
{icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen', {icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen',
hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})}, hint: s.auto ? (s.count === 0 ? 'die ganze Warteschlange' : `die ersten ${s.count} Folgen der Warteschlange`) : 'aus',
s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen', action: () => dlSet({auto: !s.auto})},
DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n, 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})})))}, 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', 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})}, action: () => dlSet({mobile: !s.mobile})},
@ -3127,9 +3362,19 @@
window.__dioraPodcasts = function (json) { window.__dioraPodcasts = function (json) {
state = typeof json === 'string' ? parse(json, state) : json; state = typeof json === 'string' ? parse(json, state) : json;
moveFeedListsToServer();
render(); 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“). // A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“).
window.__dioraOpenFeed = function (feedId) { window.__dioraOpenFeed = function (feedId) {
if (typeof showTab !== 'function') return; if (typeof showTab !== 'function') return;
@ -3179,6 +3424,7 @@
build(); build();
buildMini(); buildMini();
player = parse(N.playerState(), {kind: null}); player = parse(N.playerState(), {kind: null});
moveFeedListsToServer();
render(); render();
renderMini(); renderMini();