Podcast-Routine aus AntennaPod: Abspiel-Einstellungen fürs Konto, Listen und Stille pro Podcast; Oberfläche aus diora-android 304abf0, sw v65
- 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:
parent
3577fca9ba
commit
54fbd58293
9 changed files with 821 additions and 90 deletions
|
|
@ -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)}'
|
||||
|
|
|
|||
72
podcasts/migrations/0014_playback_routine.py
Normal file
72
podcasts/migrations/0014_playback_routine.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue