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 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)}'
|
||||||
|
|
|
||||||
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.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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
if fields:
|
for name in fields:
|
||||||
s.save(update_fields=fields)
|
setattr(s, name, first[name])
|
||||||
if 'keep_sorted' in fields:
|
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
|
_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],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,9 +358,116 @@
|
||||||
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.
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue