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

- PodcastSettings (Migration 0014): skip_back / skip_forward (5–60 s),
  smart_played, speed_presets (0,5–3,5×), keep_skipped, delete_dequeue,
  mobile_downloads, mobile_streaming (allow/ask/never), times_by_speed;
  die ersten vier sind anfangs leer (null) und werden mit POST
  settings/ {"initial": {...}} nur dort gesetzt, wo noch nichts steht
- GET/POST /podcasts/settings/ und state.settings mit allen Feldern,
  geprüft, nichts ändert sich bei einem ungültigen Wert
- PodcastFeed: episode_sort, episode_filter, skip_silence in
  feeds/<id>/settings/ und den Feeds des States
- Tempo pro Podcast bis 3,5× (Server und AntennaPod-Import)
- AntennaPod-Import: Sortierung, Filter und Stille pro Podcast, soweit
  diora sie zeigen kann; nicht darstellbare werden gezählt
- Browser-Host: Sprungweiten, "gehört" und keep_skipped aus dem Konto,
  Tempo bis 3,5×, neue Einstellungen werden gespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 18:46:55 +02:00
parent 3577fca9ba
commit 54fbd58293
9 changed files with 821 additions and 90 deletions

View file

@ -25,7 +25,7 @@ from urllib.parse import urlsplit
from django.db import transaction
from django.db.models import Q
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
from .models import MAX_SPEED, MIN_SPEED, EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
from .views import MAX_DESCRIPTION, _keep_sorted, _leave_inbox, _queue_order, _write_queue_order
CHUNK = 500 # ids per IN (...) query and rows per bulk write
@ -43,7 +43,18 @@ SUBSCRIBED = 0 # Feeds.state
MAX_FILESIZE = 4_000_000_000
SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words',
'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify')
'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify',
'episode_sort', 'episode_filter', 'skip_silence')
# Feeds.sort_order (SortOrder codes) -> episode_sort; 11 = the global order
# (not in the backup). The others (title Z-A, file name, size) diora can't show.
SORT_ORDERS = {1: 'old', 2: 'new', 3: 'title', 5: 'short', 6: 'long'}
GLOBAL_SORT = 11
# Feeds.hide (FeedItemFilter, comma-separated) -> episode_filter
FILTERS = {'unplayed': 'unplayed', 'played': 'played', 'paused': 'in_progress', 'is_favorite': 'favorites',
'queued': 'queued', 'downloaded': 'downloaded'}
# Feeds.feed_skip_silence (SkipSilence codes); 1 = the global setting
SKIP_SILENCE = {0: False, 2: True}
# Columns read from the backup, with the value to assume if an older
# AntennaPod didn't have the column yet.
@ -52,7 +63,7 @@ FEED_COLUMNS = {
'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '',
'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1,
'feed_skip_intro': 0, 'feed_skip_ending': 0, 'feed_volume_adaption': 0, 'auto_download': 1,
'episode_notification': 0, 'state': SUBSCRIBED,
'episode_notification': 0, 'state': SUBSCRIBED, 'sort_order': None, 'hide': '', 'feed_skip_silence': 1,
}
@ -156,13 +167,36 @@ def _given_updated_at():
# Feed settings
# ---------------------------------------------------------------------------
def episode_sort(code):
"""Feeds.sort_order -> (episode_sort or None, representable). None and
the global order are no setting of the feed (representable, nothing to set)."""
try:
code = int(code) if code not in (None, '') else None
except (TypeError, ValueError):
return None, False
if code is None or code == GLOBAL_SORT:
return None, True
return SORT_ORDERS.get(code), code in SORT_ORDERS
def episode_filter(raw):
"""Feeds.hide -> (episode_filter or None, representable): the filter only
if diora can show all of its terms (a part of it would be another list)."""
terms = [t.strip() for t in (raw or '').split(',') if t.strip()]
if not terms:
return None, True
if any(t not in FILTERS for t in terms):
return None, False
return list(dict.fromkeys(FILTERS[t] for t in terms)), True
def feed_settings(ap, global_auto_download=False):
"""diora settings for one AntennaPod feed row: only those AntennaPod sets
to something other than its default. Returns (settings, not_kept_updated)."""
s = {}
speed = ap['feed_playback_speed']
if speed is not None and speed > 0: # -1 = the global speed
s['playback_speed'] = min(max(round(float(speed), 2), 0.5), 3.0)
s['playback_speed'] = min(max(round(float(speed), 2), MIN_SPEED), MAX_SPEED)
for src, dst in (('feed_skip_intro', 'skip_intro'), ('feed_skip_ending', 'skip_outro')):
seconds = int(ap[src] or 0)
if seconds > 0:
@ -195,6 +229,16 @@ def feed_settings(ap, global_auto_download=False):
# (0, AntennaPod's default, leaves diora's setting alone).
if ap['episode_notification']:
s['notify'] = True
# The podcast's episode list: its order and filter, where diora has them.
sort, _ = episode_sort(ap['sort_order'])
if sort:
s['episode_sort'] = sort
filters, _ = episode_filter(ap['hide'])
if filters:
s['episode_filter'] = filters
# Skip silence: off or on for this podcast (the global setting: diora's stays).
if ap['feed_skip_silence'] in SKIP_SILENCE:
s['skip_silence'] = SKIP_SILENCE[ap['feed_skip_silence']]
return s, not_kept_updated
@ -230,6 +274,8 @@ class Stats:
skipped_feeds: list = field(default_factory=list) # (title, url)
settings: dict = field(default_factory=lambda: {name: 0 for name in SETTING_FIELDS})
not_kept_updated: int = 0
sort_unshown: int = 0 # per-feed orders / filters diora has no equivalent of
filter_unshown: int = 0
auto_download_on: int = 0
auto_download_explicit: int = 0
auto_download_global: int = 0
@ -414,6 +460,8 @@ class AntennaPodImport:
settings, not_kept_updated = feed_settings(ap, self.global_auto_download)
st.not_kept_updated += not_kept_updated
st.sort_unshown += not episode_sort(ap['sort_order'])[1]
st.filter_unshown += not episode_filter(ap['hide'])[1]
st.auto_download_explicit += ap['auto_download'] == AUTO_DOWNLOAD_ON
st.auto_download_global += ap['auto_download'] == AUTO_DOWNLOAD_GLOBAL
for name, value in settings.items():
@ -774,6 +822,7 @@ SETTING_LABELS = {
'playback_speed': 'speed', 'skip_intro': 'skip intro', 'skip_outro': 'skip outro', 'volume': 'volume',
'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration',
'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications',
'episode_sort': 'episode order', 'episode_filter': 'episode filter', 'skip_silence': 'skip silence',
}
@ -794,6 +843,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download):
+ (f', {len(st.removed_titles)} only in diora unsubscribed (--replace)' if st.removed_titles else ''),
f'Settings: changed: {settings}',
f' {st.not_kept_updated} feeds not kept updated in AntennaPod -> new episodes "ignore"',
f' episode lists diora can\'t show that way (left as they are): {st.sort_unshown} orders, '
f'{st.filter_unshown} filters',
f'Auto-download: on for {st.auto_download_on} feeds ({st.auto_download_explicit} on in AntennaPod, '
f"{st.auto_download_global} use AntennaPod's global setting -> {_on(global_auto_download)} "
f'by --global-auto-download); default for new subscriptions: {_on(st.auto_download_default)}'

View file

@ -0,0 +1,72 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0013_feed_notify'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='episode_sort',
field=models.CharField(blank=True, max_length=10),
),
migrations.AddField(
model_name='podcastfeed',
name='episode_filter',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='podcastfeed',
name='skip_silence',
field=models.BooleanField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastsettings',
name='skip_back',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastsettings',
name='skip_forward',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastsettings',
name='smart_played',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastsettings',
name='speed_presets',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastsettings',
name='keep_skipped',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='podcastsettings',
name='delete_dequeue',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='podcastsettings',
name='mobile_downloads',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='podcastsettings',
name='mobile_streaming',
field=models.CharField(choices=[('allow', 'Erlauben'), ('ask', 'Nachfragen'), ('never', 'Nie')],
default='allow', max_length=10),
),
migrations.AddField(
model_name='podcastsettings',
name='times_by_speed',
field=models.BooleanField(default=False),
),
]

View file

@ -1,6 +1,11 @@
from django.contrib.auth.models import User
from django.db import models
# Playback speeds everywhere (the apps, a podcast's own speed, the presets):
# AntennaPod goes up to 3.5x.
MIN_SPEED = 0.5
MAX_SPEED = 3.5
class PodcastFeed(models.Model):
# What happens to a new episode of this feed (one that passed the filter).
@ -54,6 +59,17 @@ class PodcastFeed(models.Model):
# Devices tell about this feed's new episodes (a notification, like
# AntennaPod's per-podcast "episode notifications").
notify = models.BooleanField(default=False)
# The podcast's episode list as the user left it on any device: its
# order ('' = newest first) and the filters that are on (all must match).
episode_sort = models.CharField(max_length=10, blank=True)
episode_filter = models.JSONField(default=list, blank=True)
# Skip silence in this podcast's episodes: null = as the device is set.
skip_silence = models.BooleanField(null=True, blank=True)
# The vocabulary of episode_sort and episode_filter: the podcast page's
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS).
EPISODE_SORTS = ('new', 'old', 'long', 'short', 'title')
EPISODE_FILTERS = ('unplayed', 'in_progress', 'favorites', 'queued', 'downloaded', 'played')
class Meta:
unique_together = ('user', 'rss_url')
@ -244,6 +260,40 @@ class PodcastSettings(models.Model):
# to download them again, then clears the list.
import_downloads = models.JSONField(default=list, blank=True)
# Playback, the same on every device (AntennaPod's playback settings).
# Null = not set yet: each device keeps its own value; the first app that
# finds one unset sends its own ("initial" in POST settings/), so nobody's
# setting changes when it moves to the account.
SKIP_CHOICES = (5, 10, 15, 20, 30, 45, 60)
skip_back = models.PositiveSmallIntegerField(null=True, blank=True) # seconds
skip_forward = models.PositiveSmallIntegerField(null=True, blank=True) # seconds
# An episode counts as played this many seconds before its end.
smart_played = models.PositiveIntegerField(null=True, blank=True)
# The speeds the speed menu offers and the notification's speed button
# goes through, ascending; null = the apps' default list.
speed_presets = models.JSONField(null=True, blank=True)
MAX_PRESETS = 12
# Skipping to the next episode (player, notification, headset, car)
# leaves the skipped one unplayed and in the queue.
keep_skipped = models.BooleanField(default=True)
# Deleting a download takes its episode out of the queue too.
delete_dequeue = models.BooleanField(default=True)
# Downloads asked for by hand may use mobile data (else they wait for
# Wi-Fi, like automatic ones).
mobile_downloads = models.BooleanField(default=True)
# Streaming an episode over mobile data: allowed, asked once per episode,
# or never (radio streams aren't affected).
STREAMING = [('allow', 'Erlauben'), ('ask', 'Nachfragen'), ('never', 'Nie')]
mobile_streaming = models.CharField(max_length=10, choices=STREAMING, default='allow')
# Remaining times and lengths are shown at the playback speed.
times_by_speed = models.BooleanField(default=False)
# The playback settings above, as the API names them; the first four
# start out unset.
PLAYBACK_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets', 'keep_skipped',
'delete_dequeue', 'mobile_downloads', 'mobile_streaming', 'times_by_speed')
UNSET_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets')
@classmethod
def for_user(cls, user):
obj, _ = cls.objects.get_or_create(user=user)

View file

@ -32,7 +32,7 @@ class Backup:
feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0,
feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption INTEGER DEFAULT 0,
auto_download INTEGER DEFAULT 0, episode_notification INTEGER DEFAULT 0, state INTEGER DEFAULT 0,
tags TEXT);
tags TEXT, sort_order TEXT, hide TEXT, feed_skip_silence INTEGER DEFAULT 1);
CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER,
read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER,
item_identifier TEXT, image_url TEXT);
@ -211,7 +211,7 @@ class FeedImportTests(ImportTestCase):
feeds = {f.rss_url.rsplit('/', 1)[1]: f for f in PodcastFeed.objects.filter(user=self.user)}
fast, slow = feeds['fast'], feeds['slow']
self.assertEqual((fast.playback_speed, fast.volume, fast.min_duration, fast.new_episodes, fast.auto_queue),
(3.0, 2.5, 0, 'queue', True))
(3.5, 2.5, 0, 'queue', True))
self.assertEqual((slow.playback_speed, slow.volume, slow.new_episodes, slow.auto_queue),
(0.5, 1.5, 'inbox', False))
self.assertEqual((feeds['loud'].volume, feeds['quiet'].volume), (2.0, 0.5))
@ -268,6 +268,51 @@ class FeedImportTests(ImportTestCase):
'https://example.org/on': True, 'https://example.org/off': False})
self.assertIn('notifications 1', out)
def test_episode_lists_and_skip_silence(self):
"""Per podcast: its episode order and filter (where diora has them) and skipping silence."""
self.diora_feed('https://example.org/mine', episode_sort='long', episode_filter=['favorites'],
skip_silence=True)
self.ap.feed('https://example.org/mine') # nothing set in AntennaPod: diora's own stay
self.ap.feed('https://example.org/series', sort_order='1', hide='unplayed', feed_skip_silence=0)
self.ap.feed('https://example.org/new', sort_order='2', hide='paused,is_favorite,downloaded,queued')
self.ap.feed('https://example.org/title', sort_order='3', hide='played', feed_skip_silence=2)
self.ap.feed('https://example.org/short', sort_order='5')
self.ap.feed('https://example.org/long', sort_order='6', feed_skip_silence=1)
self.ap.feed('https://example.org/global', sort_order='11', hide='')
self.ap.feed('https://example.org/z-a', sort_order='4', hide='unplayed,not_downloaded')
self.ap.feed('https://example.org/size', sort_order='10', hide='new')
out = self.run_import()
got = {f.rss_url.rsplit('/', 1)[1]: (f.episode_sort, f.episode_filter, f.skip_silence)
for f in PodcastFeed.objects.filter(user=self.user)}
self.assertEqual(got, {
'mine': ('long', ['favorites'], True),
'series': ('old', ['unplayed'], False),
'new': ('new', ['in_progress', 'favorites', 'downloaded', 'queued'], None),
'title': ('title', ['played'], True),
'short': ('short', [], None),
'long': ('long', [], None),
'global': ('', [], None),
'z-a': ('', [], None), # title Z-A, "not downloaded": nothing diora can show
'size': ('', [], None),
})
self.assertIn('episode order 5, episode filter 3, skip silence 2', out)
self.assertIn("episode lists diora can't show that way (left as they are): 2 orders, 2 filters", out)
# Through the API as the apps see it.
self.client.force_login(self.user)
feeds = {f['rss_url'].rsplit('/', 1)[1]: f for f in self.client.get('/podcasts/state/').json()['feeds']}
self.assertEqual((feeds['series']['episode_sort'], feeds['series']['episode_filter'],
feeds['series']['skip_silence']), ('old', ['unplayed'], False))
def test_backup_without_list_and_silence_columns(self):
for column in ('sort_order', 'hide', 'feed_skip_silence'):
self.ap.db.execute(f'ALTER TABLE Feeds DROP COLUMN {column}')
self.ap.feed('https://example.org/old')
self.run_import()
f = PodcastFeed.objects.get(user=self.user, rss_url='https://example.org/old')
self.assertEqual((f.episode_sort, f.episode_filter, f.skip_silence), ('', [], None))
def test_backup_without_episode_notification_column(self):
self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification')
self.ap.feed('https://example.org/old')

View file

@ -12,6 +12,13 @@ from .views import FeedFetch, _refresh_feed
_parse = feedparser.parse
# The account's playback settings as a new user has them.
PLAYBACK_DEFAULTS = {
'skip_back': None, 'skip_forward': None, 'smart_played': None, 'speed_presets': None,
'keep_skipped': True, 'delete_dequeue': True, 'mobile_downloads': True, 'mobile_streaming': 'allow',
'times_by_speed': False,
}
def _ok(rss, **kw):
"""What _fetch_feed returns for a 200 with this RSS."""
@ -327,7 +334,8 @@ class RefreshAllTests(TestCase):
# Shownotes stay out of the one-request state (they can be large).
self.assertNotIn('description', data['inbox'][0])
self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front',
'keep_sorted': '', 'auto_download_default': False})
'keep_sorted': '', 'auto_download_default': False,
**PLAYBACK_DEFAULTS})
# ---------------------------------------------------------------------------
@ -1031,7 +1039,7 @@ class OpmlExportTests(TestCase):
FEED_DEFAULTS = {
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox',
'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
'notify': False,
'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None,
}
@ -1058,6 +1066,7 @@ class FeedSettingsTests(TestCase):
'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0,
'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
'auto_queue': False, 'auto_download': False, 'notify': False,
'episode_sort': '', 'episode_filter': [], 'skip_silence': None,
})
self.feed.refresh_from_db()
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),
@ -1071,12 +1080,34 @@ class FeedSettingsTests(TestCase):
def test_every_allowed_volume_and_the_speed_limits(self):
for v in (0.2, 0.5, 1.0, 1.5, 2.0, 2.5):
self.assertEqual(self.post({'volume': v}).json()['volume'], v)
for s in (0.5, 3, 3.0):
for s in (0.5, 3, 3.0, 3.5):
self.assertEqual(self.post({'playback_speed': s}).json()['playback_speed'], s)
def test_episode_list_and_skip_silence(self):
r = self.post({'episode_sort': 'old', 'episode_filter': ['unplayed', 'favorites', 'unplayed'],
'skip_silence': False})
self.assertEqual((r.json()['episode_sort'], r.json()['episode_filter'], r.json()['skip_silence']),
('old', ['unplayed', 'favorites'], False))
self.feed.refresh_from_db()
self.assertEqual((self.feed.episode_sort, self.feed.episode_filter, self.feed.skip_silence),
('old', ['unplayed', 'favorites'], False))
for sort in PodcastFeed.EPISODE_SORTS:
self.assertEqual(self.post({'episode_sort': sort}).json()['episode_sort'], sort)
# Back to the defaults: newest first, all episodes, as the device is set.
r = self.post({'episode_sort': '', 'episode_filter': [], 'skip_silence': None})
self.assertEqual((r.json()['episode_sort'], r.json()['episode_filter'], r.json()['skip_silence']), ('', [], None))
self.assertTrue(self.post({'skip_silence': True}).json()['skip_silence'])
self.assertEqual(self.post({'episode_filter': list(PodcastFeed.EPISODE_FILTERS)}).json()['episode_filter'],
list(PodcastFeed.EPISODE_FILTERS))
state = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}[self.feed.id]
self.assertEqual((state['episode_sort'], state['episode_filter'], state['skip_silence']),
('', list(PodcastFeed.EPISODE_FILTERS), True))
def test_invalid_values_name_the_field_and_change_nothing(self):
bad = [
('playback_speed', 0.4), ('playback_speed', 3.5), ('playback_speed', 'fast'), ('playback_speed', True),
('playback_speed', 0.4), ('playback_speed', 3.6), ('playback_speed', 'fast'), ('playback_speed', True),
('episode_sort', 'played'), ('episode_sort', 1), ('episode_filter', 'unplayed'),
('episode_filter', ['history']), ('episode_filter', [1]), ('skip_silence', 1), ('skip_silence', 'true'),
('skip_intro', -1), ('skip_intro', 601), ('skip_intro', 1.5), ('skip_intro', '10'), ('skip_intro', None),
('skip_outro', 601), ('volume', 0.7), ('volume', '1.0'), ('volume', None),
('new_episodes', 'later'), ('new_episodes', None),
@ -2384,3 +2415,89 @@ class BulkFavoriteTests(TestCase):
self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401)
class PlaybackSettingsTests(TestCase):
"""The account's playback settings: skip lengths, speed presets, what
skipping and deleting do, mobile data, times at the speed."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def post(self, body):
return self.client.post('/podcasts/settings/', json.dumps(body), content_type='application/json')
def settings(self):
return {k: getattr(PodcastSettings.for_user(self.user), k) for k in PLAYBACK_DEFAULTS}
def test_defaults(self):
r = self.client.get('/podcasts/settings/').json()
self.assertEqual({k: r[k] for k in PLAYBACK_DEFAULTS}, PLAYBACK_DEFAULTS)
self.assertEqual(r['skip_choices'], [5, 10, 15, 20, 30, 45, 60])
self.assertEqual(r['speed_range'], [0.5, 3.5])
def test_change_all(self):
body = {'skip_back': 10, 'skip_forward': 30, 'smart_played': 30, 'speed_presets': [3, 1, 2.5, 2, 2.0],
'keep_skipped': False, 'delete_dequeue': False, 'mobile_downloads': False,
'mobile_streaming': 'ask', 'times_by_speed': True}
r = self.post(body)
self.assertEqual(r.status_code, 200, r.content)
expected = {**body, 'speed_presets': [1.0, 2.0, 2.5, 3.0]}
self.assertEqual({k: r.json()[k] for k in PLAYBACK_DEFAULTS}, expected)
self.assertEqual(self.settings(), expected)
self.assertEqual({k: self.client.get('/podcasts/state/').json()['settings'][k] for k in PLAYBACK_DEFAULTS},
expected)
# Only what is sent changes; null unsets again.
r = self.post({'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None})
self.assertEqual({k: r.json()[k] for k in ('mobile_streaming', 'speed_presets', 'skip_back', 'skip_forward')},
{'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None, 'skip_forward': 30})
def test_presets_up_to_three_and_a_half(self):
self.assertEqual(self.post({'speed_presets': [0.5, 3.5, 1.234]}).json()['speed_presets'], [0.5, 1.23, 3.5])
self.assertEqual(self.post({'speed_presets': [1.0] * 5}).json()['speed_presets'], [1.0])
def test_invalid_values_change_nothing(self):
self.post({'skip_back': 15})
bad = [
('skip_back', 7), ('skip_back', 0), ('skip_back', '10'), ('skip_back', 10.5), ('skip_back', True),
('skip_forward', 90), ('smart_played', -1), ('smart_played', 3601), ('smart_played', 'x'),
('speed_presets', []), ('speed_presets', [0.4]), ('speed_presets', [3.6]), ('speed_presets', ['1']),
('speed_presets', 1.5), ('speed_presets', [0.5 + 0.25 * i for i in range(13)]),
('keep_skipped', 1), ('delete_dequeue', None), ('mobile_downloads', 'false'), ('times_by_speed', 0),
('mobile_streaming', 'sometimes'), ('mobile_streaming', None), ('enqueue_position', 'middle'),
]
for name, value in bad:
with self.subTest(name=name, value=value):
r = self.post({'keep_skipped': False, name: value} if name != 'keep_skipped' else {name: value})
self.assertEqual(r.status_code, 400)
self.assertIn(name, r.json()['error'])
self.assertEqual(r.json()['field'], name)
self.assertEqual(self.settings(), {**PLAYBACK_DEFAULTS, 'skip_back': 15})
self.assertEqual(self.post([1]).status_code, 400)
self.assertEqual(self.post({'initial': [1]}).status_code, 400)
def test_initial_sets_only_what_is_unset(self):
"""An app hands over its own values once: they only fill what the account doesn't have yet."""
self.post({'skip_forward': 45})
r = self.post({'initial': {'skip_back': 10, 'skip_forward': 30, 'smart_played': 60,
'keep_skipped': False}}) # keep_skipped isn't one that starts unset
self.assertEqual(r.status_code, 200, r.content)
self.assertEqual({k: r.json()[k] for k in ('skip_back', 'skip_forward', 'smart_played', 'keep_skipped')},
{'skip_back': 10, 'skip_forward': 45, 'smart_played': 60, 'keep_skipped': True})
# A second device comes too late: nothing changes.
r = self.post({'initial': {'skip_back': 5, 'smart_played': 0, 'speed_presets': [1, 2]}})
self.assertEqual({k: r.json()[k] for k in ('skip_back', 'smart_played', 'speed_presets')},
{'skip_back': 10, 'smart_played': 60, 'speed_presets': [1.0, 2.0]})
# Invalid initial values are refused like any other.
r = self.post({'initial': {'skip_back': 7}})
self.assertEqual((r.status_code, r.json()['field']), (400, 'skip_back'))
# Initial and a change at once: the change wins.
PodcastSettings.objects.filter(user=self.user).update(smart_played=None)
r = self.post({'smart_played': 120, 'initial': {'smart_played': 30}})
self.assertEqual(r.json()['smart_played'], 120)
def test_login(self):
self.client.logout()
self.assertEqual(self.post({'skip_back': 10}).status_code, 401)
self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401)

View file

@ -27,7 +27,7 @@ from . import feedext
from . import transcripts as transcripts_mod
from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue,
PodcastSettings, counts_as_played,
MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
)
from .shownotes import extract_timestamps, sanitize_html
@ -725,7 +725,7 @@ def feed_list(request):
FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
'notify',
'notify', 'episode_sort', 'episode_filter', 'skip_silence',
)
@ -878,11 +878,34 @@ def _whole(value, low, high):
def _speed(value):
if value is None:
return None
if not _number(value) or not 0.5 <= value <= 3.0:
if not _number(value) or not MIN_SPEED <= value <= MAX_SPEED:
raise ValueError
return float(value)
def _episode_sort(value):
if value in (None, ''):
return ''
if value not in PodcastFeed.EPISODE_SORTS:
raise ValueError
return value
def _episode_filter(value):
"""A list of filter ids (each once, in the given order); [] = all episodes."""
if value is None:
return []
if not isinstance(value, list) or any(v not in PodcastFeed.EPISODE_FILTERS for v in value):
raise ValueError
return list(dict.fromkeys(value))
def _optional_boolean(value):
if value is not None and not isinstance(value, bool):
raise ValueError
return value
def _volume(value):
if not _number(value) or float(value) not in PodcastFeed.VOLUMES:
raise ValueError
@ -920,6 +943,9 @@ FEED_SETTING_PARSERS = {
'min_duration': lambda v: _whole(v, 0, 600),
'auto_download': _boolean,
'notify': _boolean,
'episode_sort': _episode_sort,
'episode_filter': _episode_filter,
'skip_silence': _optional_boolean,
}
@ -1557,8 +1583,7 @@ def podcast_state(request):
'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False),
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default},
'settings': _settings_data(s),
'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
'server_time': timezone.now().isoformat(),
})
@ -1677,9 +1702,90 @@ def queue_reorder(request):
return JsonResponse({'ok': True})
def _choice(choices, what):
def parse(value):
if value not in choices:
raise LookupError(f'unknown {what}')
return value
return parse
def _skip_seconds(value):
if value is None:
return None
if not _number(value) or value != int(value) or int(value) not in PodcastSettings.SKIP_CHOICES:
raise ValueError
return int(value)
def _smart_played(value):
return None if value is None else _whole(value, 0, 3600)
def _speed_presets(value):
"""Up to MAX_PRESETS speeds (MIN_SPEED to MAX_SPEED), rounded to two
places, each once, ascending; null = the apps' default list."""
if value is None:
return None
if not isinstance(value, list) or not 1 <= len(value) <= PodcastSettings.MAX_PRESETS:
raise ValueError
out = set()
for v in value:
if not _number(v) or not math.isfinite(v) or not MIN_SPEED <= v <= MAX_SPEED:
raise ValueError
out.add(round(float(v), 2))
return sorted(out)
# POST settings/: each field's parser; a LookupError's text is the error.
SETTING_PARSERS = {
'enqueue_position': _choice(QUEUE_POSITIONS, 'enqueue_position'),
'auto_queue_position': _choice(QUEUE_POSITIONS, 'auto_queue_position'),
'keep_sorted': _choice(KEEP_SORTED, 'keep_sorted'),
'auto_download_default': _boolean,
'skip_back': _skip_seconds,
'skip_forward': _skip_seconds,
'smart_played': _smart_played,
'speed_presets': _speed_presets,
'keep_skipped': _boolean,
'delete_dequeue': _boolean,
'mobile_downloads': _boolean,
'mobile_streaming': _choice({key for key, _ in PodcastSettings.STREAMING}, 'mobile_streaming'),
'times_by_speed': _boolean,
}
def _settings_data(s):
return {
'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default,
**{name: getattr(s, name) for name in PodcastSettings.PLAYBACK_FIELDS},
}
def _parse_settings(body, names):
"""{field: value} for those of `names` in body; raises (field, error text)."""
out = {}
for name in names:
if name not in body:
continue
try:
out[name] = SETTING_PARSERS[name](body[name])
except LookupError as e:
raise ValueError(name, str(e))
except (ValueError, TypeError, OverflowError):
raise ValueError(name, f'invalid {name}')
return out
@csrf_exempt
def podcast_settings(request):
"""GET the user's podcast settings; POST changes some of them."""
"""GET the user's podcast settings; POST changes some of them (any of
SETTING_PARSERS; nothing changes if one value is invalid).
POST {"initial": {...}} sets the playback settings that are still unset
(null) and leaves the others: how an app hands over its own values once,
the first time it finds the account without them."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
s = PodcastSettings.for_user(request.user)
@ -1688,35 +1794,40 @@ def podcast_settings(request):
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
fields = []
for name in ('enqueue_position', 'auto_queue_position'):
if name in body:
if body[name] not in QUEUE_POSITIONS:
return JsonResponse({'error': f'unknown {name}'}, status=400)
setattr(s, name, body[name])
fields.append(name)
if 'keep_sorted' in body:
if body['keep_sorted'] not in KEEP_SORTED:
return JsonResponse({'error': 'unknown keep_sorted'}, status=400)
s.keep_sorted = body['keep_sorted']
fields.append('keep_sorted')
if 'auto_download_default' in body:
if not isinstance(body['auto_download_default'], bool):
return JsonResponse({'error': 'invalid auto_download_default'}, status=400)
s.auto_download_default = body['auto_download_default']
fields.append('auto_download_default')
if fields:
s.save(update_fields=fields)
if 'keep_sorted' in fields:
if not isinstance(body, dict):
return JsonResponse({'error': 'invalid JSON'}, status=400)
initial = body.get('initial') or {}
if not isinstance(initial, dict):
return JsonResponse({'error': 'invalid initial', 'field': 'initial'}, status=400)
try:
changes = _parse_settings(body, SETTING_PARSERS)
first = _parse_settings(initial, PodcastSettings.UNSET_FIELDS)
except ValueError as e:
name, text = e.args
return JsonResponse({'error': text, 'field': name}, status=400)
if first:
# Only where nothing is set yet, decided by the database (two
# devices handing theirs over at once: the first one wins).
with transaction.atomic():
s = PodcastSettings.objects.select_for_update().get(pk=s.pk)
fields = [name for name, value in first.items() if getattr(s, name) is None and value is not None]
for name in fields:
setattr(s, name, first[name])
if fields:
s.save(update_fields=fields)
if changes:
for name, value in changes.items():
setattr(s, name, value)
s.save(update_fields=list(changes))
if 'keep_sorted' in changes:
_keep_sorted(request.user, s.keep_sorted) # sorted right away
elif request.method != 'GET':
return JsonResponse({'error': 'method not allowed'}, status=405)
return JsonResponse({
'enqueue_position': s.enqueue_position,
'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted,
'auto_download_default': s.auto_download_default,
'positions':[{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
**_settings_data(s),
'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
'skip_choices': list(PodcastSettings.SKIP_CHOICES),
'speed_range': [MIN_SPEED, MAX_SPEED],
})

View file

@ -65,7 +65,25 @@
const POSITIONS = ['back', 'front', 'after_current'];
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration'];
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify',
'episode_sort', 'episode_filter', 'skip_silence'];
// The account's playback settings (diora-web PodcastSettings) and what each may be.
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
const MIN_SPEED = 0.5;
const MAX_SPEED = 3.5;
const ACCOUNT_SETTINGS = {
skip_back: v => v === null || SKIPS.indexOf(v) >= 0,
skip_forward: v => v === null || SKIPS.indexOf(v) >= 0,
smart_played: v => v === null || (v === Math.round(v) && v >= 0 && v <= 3600),
speed_presets: v => v === null || (Array.isArray(v) && v.length > 0 && v.length <= 12 &&
v.every(x => typeof x === 'number' && x >= MIN_SPEED && x <= MAX_SPEED)),
keep_skipped: v => typeof v === 'boolean',
delete_dequeue: v => typeof v === 'boolean',
mobile_downloads: v => typeof v === 'boolean',
mobile_streaming: v => ['allow', 'ask', 'never'].indexOf(v) >= 0,
times_by_speed: v => typeof v === 'boolean',
auto_download_default: v => typeof v === 'boolean',
};
function empty() {
return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}};
@ -340,6 +358,14 @@
settings.keep_sorted = body.keep_sorted = changes.keep_sorted;
if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once
}
for (const name of Object.keys(ACCOUNT_SETTINGS)) {
if (!(name in changes)) continue;
let v = changes[name];
if (name === 'speed_presets' && Array.isArray(v)) {
v = v.map(x => Math.round(x * 100) / 100).filter((x, i, a) => a.indexOf(x) === i).sort((a, b) => a - b);
}
if (ACCOUNT_SETTINGS[name](v)) settings[name] = body[name] = v;
}
if (Object.keys(body).length) queueOp('/podcasts/settings/', body);
});
}
@ -371,20 +397,29 @@
// ---- this device's playback settings (PlaybackPrefs.kt) --------------------------
const CHOICES = {
skip_back: [5, 10, 15, 30],
skip_forward: [10, 15, 30, 45, 60],
smart_played: [0, 15, 30, 60, 120, 300, 600],
skip_back: SKIPS,
skip_forward: SKIPS,
smart_played: [0, 15, 30, 45, 60, 120, 300, 600],
};
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
sleep_last: 15, sleep_auto: false}, load('prefs', {}));
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
function eff(name) {
const v = (state.settings || {})[name];
return v != null ? v : prefs[name];
}
const keepSkipped = () => (state.settings || {}).keep_skipped !== false;
function setPref(name, value) {
if (name === 'speed') {
if (!(value >= 0.5 && value <= 3)) return;
if (!(value >= MIN_SPEED && value <= MAX_SPEED)) return;
prefs.speed = value;
} else if (CHOICES[name]) {
if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
prefs[name] = Math.round(value);
// The account's setting, where the server has it: the same on every device.
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
} else if (name === 'remove_played') {
prefs.remove_played = !!value;
} else {
@ -395,8 +430,8 @@
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() {
return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward,
remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}};
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}};
}
function speedFor(feedId) {
@ -519,7 +554,7 @@
// Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished.
function almostEnded() {
const dur = durMs();
return dur > 0 && posMs() >= dur - Math.max(prefs.smart_played * 1000, 1000);
return dur > 0 && posMs() >= dur - Math.max(eff('smart_played') * 1000, 1000);
}
function nextEntry() {
@ -601,12 +636,15 @@
}
// Move to another episode (onMediaItemTransition): the one we leave counts as
// finished when it ended (auto) or is left within its last seconds; else it keeps its place.
function goTo(e, auto, play) {
// finished when it ended (auto) or is left within its last seconds; one skipped
// to the next (⏭, media keys) counts as played when skipped ones aren't kept
// (the account's keep_skipped); else it keeps its place.
function goTo(e, auto, play, skipped) {
const prev = curId();
let stop = false;
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev);
else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
else saveNow(prev, true);
}
unload();
@ -682,15 +720,16 @@
function skip(forward) {
const pos = posMs();
if (!forward) { seek(pos - prefs.skip_back * 1000); return; }
const to = pos + prefs.skip_forward * 1000;
if (!forward) { seek(pos - eff('skip_back') * 1000); return; }
const to = pos + eff('skip_forward') * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
}
// To the next episode: the one left is skipped (see goTo).
function next() {
const n = nextEntry();
if (n) goTo(n, false, wantPlay);
if (n) goTo(n, false, wantPlay, true);
}
function playEpisode(id) {

View file

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

View file

@ -1,4 +1,4 @@
// Copied from diora-android 6b0855e 2026-10-02: web-native/static/native-podcast.js
// Copied from diora-android 304abf0 2026-10-02: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@ -36,7 +36,13 @@
{value: 'front', label: 'An den Anfang'},
{value: 'after_current', label: 'Nach der laufenden Folge'},
];
// The speed menu's choices while the account has no presets of its own (settings().speed_presets).
const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2];
const MIN_SPEED = 0.5;
const MAX_SPEED = 3.5;
const MAX_PRESETS = 12;
// Skip lengths back and forward (seconds), as AntennaPod offers them.
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
function parse(s, fallback) {
try { return JSON.parse(s); } catch (e) { return fallback; }
@ -61,6 +67,29 @@
const feeds = () => state.feeds || [];
const settings = () => state.settings || {};
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
// The account's speed presets (null there: the default list).
const presets = () => {
const p = settings().speed_presets;
return Array.isArray(p) && p.length ? p.slice().sort((a, b) => a - b) : SPEEDS;
};
// Skipping to the next episode keeps the skipped one (AntennaPod's "keep skipped episodes").
const keepSkipped = () => settings().keep_skipped !== false;
// "Zeiten an das Tempo anpassen": lengths and remaining times as long as they take at the speed.
const bySpeed = () => !!settings().times_by_speed;
let prefsSeen = null;
let prefsAt = 0;
function speedOf(feedId) {
if (!prefsSeen || Date.now() - prefsAt > 2000) { prefsSeen = devicePrefs(); prefsAt = Date.now(); }
const f = feedId ? feeds().find(x => x.id === feedId) : null;
return f && f.playback_speed > 0 ? f.playback_speed : prefsSeen.speed > 0 ? prefsSeen.speed : 1;
}
// Seconds as shown: at the podcast's speed (or `speed`), when so set.
function shown(seconds, feedId, speed) {
if (!bySpeed()) return seconds;
const s = speed > 0 ? speed : speedOf(feedId);
return s > 0 ? seconds / s : seconds;
}
// ---- small helpers ---------------------------------------------------------------
@ -256,16 +285,23 @@
{icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position),
action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')},
removePlayedItem(),
keepSkippedItem(),
deleteDequeueItem(),
]),
group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“',
'headset_next' in prefs && 'Kopfhörer', 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [
skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]),
skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]),
group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“',
'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer',
'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [
skipItem('skip_back', 'Zurückspringen', SKIPS),
skipItem('skip_forward', 'Vorspringen', SKIPS),
'speed_presets' in s && {icon: '»', label: 'Tempo-Voreinstellungen', hint: presets().map(speedLabel).join(' · '),
action: () => presetsEditor()},
silenceItem(),
playedItem(),
timesItem(),
headsetItem(),
notifyItem(),
]),
mobileGroup(),
DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads},
DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [
// Account-wide (server): new subscriptions start with auto-download on or off.
@ -302,12 +338,17 @@
})))};
}
// Skip lengths and "counts as played" belong to the account where the server knows them
// (the host sends them there, PlaybackPrefs.set); else to this device.
const where = name => (name in settings() ? 'für alle Geräte' : 'auf diesem Gerät');
function skipItem(name, label, choices) {
if (!N.podcastSetPrefValue) return null;
const now = devicePrefs()[name];
return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`,
action: () => openSheet(label, choices.map(c => ({
icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c),
action: () => openSheet(`${label} (${where(name)})`, choices.map(c => ({
icon: c === now ? '●' : '○', label: `${c} Sekunden`, active: c === now,
action: () => { N.podcastSetPrefValue(name, c); toast(`${label}: ${c} Sekunden`); },
})))};
}
@ -317,9 +358,116 @@
if (now == null) return null;
const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende');
return {icon: '✓', label: 'Als gehört zählen', hint: label(now),
action: () => openSheet('Eine Folge zählt als gehört …', [0, 15, 30, 60, 120, 300, 600].map(v => ({
icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v),
})))};
action: () => openSheet(`Eine Folge zählt als gehört … (${where('smart_played')})`,
[0, 15, 30, 45, 60, 120, 300, 600].map(v => ({
icon: v === now ? '●' : '○', label: label(v), active: v === now,
action: () => N.podcastSetPrefValue('smart_played', v),
})))};
}
// An account setting (server: POST /podcasts/settings/), through the host.
function setAccount(name, value, text) {
if (!N.podcastSettings) return;
N.podcastSettings(JSON.stringify({[name]: value}));
if (text) toast(text);
}
function accountSwitch(name, label, onHint, offHint, def) {
const s = settings();
if (!(name in s)) return null;
const on = s[name] == null ? def : !!s[name];
return {icon: on ? '☑' : '☐', label, hint: on ? onHint : offHint,
action: () => setAccount(name, !on, `${label}: ${on ? 'aus' : 'an'}`)};
}
// AntennaPod's "keep skipped episodes": ⏭, "Folge überspringen", headset and car go on without marking.
function keepSkippedItem() {
return accountSwitch('keep_skipped', 'Übersprungene Folgen behalten',
'an: wer zur nächsten springt, lässt die Folge ungehört in der Warteschlange',
'aus: übersprungene Folgen zählen als gehört', true);
}
// AntennaPod's "delete removes from queue".
function deleteDequeueItem() {
if (!DL) return null;
return accountSwitch('delete_dequeue', 'Löschen nimmt aus der Warteschlange',
'an: wer einen Download löscht, nimmt die Folge auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true);
}
// AntennaPod's "adjust media info to playback speed".
function timesItem() {
return accountSwitch('times_by_speed', 'Zeiten an das Tempo anpassen',
'an: Längen und Restzeiten so, wie lange sie beim eingestellten Tempo dauern', 'aus: Längen wie bei 1×', false);
}
// What may use mobile data: downloads by hand (account), automatic ones (this device), streaming (account).
const STREAMING = [
{value: 'allow', label: 'Erlauben'},
{value: 'ask', label: 'Nachfragen', hint: 'einmal pro Folge'},
{value: 'never', label: 'Nie', hint: 'nur WLAN oder heruntergeladene Folgen'},
];
function mobileGroup() {
const s = settings();
const items = () => {
const now = settings();
const dls = DL ? parse(N.podcastDownloadSettings(), dlSettings) : {};
const streaming = STREAMING.find(c => c.value === now.mobile_streaming) || STREAMING[0];
return [
DL && accountSwitch('mobile_downloads', 'Downloads per Hand über mobile Daten',
'an: was du von Hand lädst, lädt auch unterwegs', 'aus: wartet aufs WLAN, wie automatische Downloads', true),
DL && {icon: dls.mobile ? '☑' : '☐', label: 'Automatische Downloads über mobile Daten',
hint: (dls.mobile ? 'an' : 'aus: nur im WLAN') + ' · dieses Gerät', action: () => dlSet({mobile: !dls.mobile})},
N.podcastAllowStream && 'mobile_streaming' in now && {icon: '⇵', label: 'Streamen über mobile Daten',
hint: streaming.label + ' · Radio ist davon nicht betroffen',
action: () => openSheet('Folgen über mobile Daten streamen', STREAMING.map(c => ({
icon: c.value === streaming.value ? '●' : '○', label: c.label, hint: c.hint || null, active: c.value === streaming.value,
action: () => setAccount('mobile_streaming', c.value, `Streamen über mobile Daten: ${c.label}`),
})))},
].filter(Boolean);
};
if (!('mobile_downloads' in s || 'mobile_streaming' in s) || !items().length) return null;
return {icon: '📶', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())};
}
// ---- speed presets (the account's; AntennaPod's speed presets, up to 3.5×) ----
const roundSpeed = v => Math.round(v * 100) / 100;
function savePresets(list) {
setAccount('speed_presets', list);
setTimeout(() => presetsEditor(list || SPEEDS), 60);
}
function presetsEditor(list) {
const now = (list || presets()).slice().sort((a, b) => a - b);
const own = Array.isArray(settings().speed_presets) || !!list;
openSheet('Tempo-Voreinstellungen', now.map(v => ({
icon: '✕', label: speedLabel(v), hint: now.length > 1 ? 'antippen zum Entfernen' : 'eine bleibt immer',
action: () => { if (now.length > 1) savePresets(now.filter(x => x !== v)); else toast('Eine Voreinstellung bleibt immer'); },
})).concat([
{icon: '+', label: 'Tempo hinzufügen', hint: `${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`,
action: () => addPreset(now)},
own && {icon: '↺', label: 'Standardliste', hint: SPEEDS.map(speedLabel).join(' · '), action: () => savePresets(null)},
].filter(Boolean)));
}
function addPreset(now) {
if (now.length >= MAX_PRESETS) { toast(`Höchstens ${MAX_PRESETS} Voreinstellungen`); return; }
const add = v => savePresets(now.concat(roundSpeed(v)).sort((a, b) => a - b));
const choices = [];
for (let v = MIN_SPEED; v <= MAX_SPEED + 0.001; v += 0.25) {
if (!now.some(x => Math.abs(x - v) < 0.001)) choices.push(roundSpeed(v));
}
openSheet('Tempo hinzufügen', choices.map(v => ({icon: '+', label: speedLabel(v), action: () => add(v)}))
.concat(typeof customPrompt === 'function' ? [{icon: '✎', label: 'Eigener Wert …', hint: 'z. B. 1,65', action: async () => {
const t = await customPrompt(`Tempo (${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}):`, '');
if (t == null) return;
const v = roundSpeed(parseFloat(String(t).replace(',', '.').replace(/[×x]/g, '')));
if (!(v >= MIN_SPEED && v <= MAX_SPEED)) { toast(`Bitte eine Zahl von ${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`); return; }
if (now.some(x => Math.abs(x - v) < 0.001)) { toast(`${speedLabel(v)} ist schon dabei`); return; }
add(v);
}}] : []));
}
// A host without headset buttons or silence skipping (the browser) leaves them out of its prefs.
@ -335,17 +483,20 @@
// The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's.
const NOTIFY = [
{key: 'next', label: 'Folge überspringen', hint: 'zählt als gehört, die nächste spielt'},
{key: 'chapter', label: 'Nächstes Kapitel', hint: 'wenn die Folge Kapitel hat'},
{key: 'speed', label: 'Tempo', hint: '1× · 1,2× · 1,5× · 1,75× · 2×'},
{key: 'sleep', label: 'Sleep-Timer', hint: '15 Minuten an / aus'},
{key: 'next', label: 'Folge überspringen',
hint: () => (keepSkipped() ? 'die nächste spielt, diese bleibt ungehört eingereiht' : 'zählt als gehört, die nächste spielt')},
{key: 'chapter', label: 'Nächstes Kapitel', hint: () => 'wenn die Folge Kapitel hat'},
// The account's presets; without any, the app's own list.
{key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length
? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')},
{key: 'sleep', label: 'Sleep-Timer', hint: () => '15 Minuten an / aus'},
];
function notifyItem() {
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
const now = devicePrefs().notify_buttons;
const names = keys => keys.map(k => (NOTIFY.find(n => n.key === k) || {}).label).filter(Boolean).join(', ') || 'keine';
const sheet = chosen => openSheet('Zwei Knöpfe in der Benachrichtigung', NOTIFY.map(n => ({
icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint,
icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint(),
// A third one replaces the one chosen first.
action: () => {
const next = chosen.includes(n.key) ? chosen.filter(k => k !== n.key) : chosen.concat(n.key).slice(-2);
@ -749,7 +900,7 @@
if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null;
const box = el('div', 'nui-pod-offer');
const s = parse(N.podcastDownloadSettings(), {});
const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? s.count || 0 : 0);
const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? (s.count === 0 ? queue().length : s.count || 0) : 0);
const raise = s.limit > 0 && needed > s.limit
? ` „Höchstens behalten“ geht dafür von ${s.limit} auf ${[5, 10, 25, 50, 100, 500].find(n => n >= needed) || 'unbegrenzt'}.` : '';
box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` +
@ -791,7 +942,7 @@
const meta = [];
// A played episode, or a place past the feed's (often wrong) length: just the length.
const started = e.pos > 30 && !e.played && e.pos < e.duration - 30;
if (e.duration) meta.push(started ? minutes(e.duration - e.pos) + ' übrig' : minutes(e.duration));
if (e.duration) meta.push(started ? minutes(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId)));
if (opts.showDate && e.date) meta.push(day(e.date));
if (opts.showFeed && e.feed) meta.push(e.feed);
main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
@ -829,7 +980,7 @@
empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
return;
}
const total = items.reduce((n, e) => n + Math.max(0, (e.duration || 0) - (e.pos || 0)), 0);
const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0);
if (selected) {
root.append(selectionBar(items, [
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
@ -1166,12 +1317,28 @@
root.append(list);
}
// A podcast's list: its filters and order, remembered per podcast on this device.
// A podcast's list: its filters and order, per podcast. On the server (episode_sort,
// episode_filter: the same on every device) where it knows them; else on this device.
const serverLists = live => !!live && 'episode_sort' in live && !!N.podcastFeedSettings;
function feedListPrefs(id) {
const live = feeds().find(x => x.id === id);
if (serverLists(live)) {
return {sort: live.episode_sort || 'new', filters: Array.isArray(live.episode_filter) ? live.episode_filter : []};
}
try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; }
}
function saveFeedListPrefs(id, prefs) {
const live = feeds().find(x => x.id === id);
if (serverLists(live)) {
const sort = prefs.sort === 'new' ? '' : prefs.sort;
// Shows at once; the host sends it on.
live.episode_sort = sort;
live.episode_filter = prefs.filters.slice();
N.podcastFeedSettings(id, JSON.stringify({episode_sort: sort, episode_filter: prefs.filters}));
return;
}
try {
const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}');
if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs;
@ -1179,6 +1346,26 @@
} catch (e) {}
}
// Once the server keeps the lists: what this device remembered goes there (where the
// server has nothing set for that podcast yet), then the device forgets it.
function moveFeedListsToServer() {
if (!N.podcastFeedSettings) return;
let all = null;
try { all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || 'null'); } catch (e) { all = null; }
if (!all) return;
const list = feeds();
if (!list.length || !list.some(f => 'episode_sort' in f)) return;
try { localStorage.removeItem('nui_pod_feedlists'); } catch (e) { return; }
for (const id of Object.keys(all)) {
const live = list.find(f => String(f.id) === id);
const p = all[id] || {};
if (!serverLists(live) || live.episode_sort || (live.episode_filter || []).length) continue;
const filters = (Array.isArray(p.filters) ? p.filters : []).filter(x => FILTERS.some(y => y.id === x && !y.all));
const sort = EP_SORTS.some(s => s.id === p.sort && !s.all) ? p.sort : 'new';
if (filters.length || sort !== 'new') saveFeedListPrefs(live.id, {filters, sort});
}
}
// ---- a podcast's info page: description, website, feed address, numbers ----------------
// In the shownotes' sheet (closeNotes closes it, also with the back button).
@ -1295,14 +1482,33 @@
})));
row.append(
chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`,
SPEEDS.map(v => ({icon: own.speed === v ? '●' : '○', label: speedLabel(v), action: () => set('speed', v)}))
.concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))),
speedChoices(own.speed).map(v => ({icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v),
action: () => set('speed', v)}))
.concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}])
.concat(presetsEditItem()))),
chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')),
chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')),
chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`,
[[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']]
.map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))),
);
// Skip silence for this podcast (server), where this player can skip silence at all.
if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) {
const mine = live.skip_silence;
const device = !!devicePrefs().skip_silence;
const b = el('button', 'nui-pod-chip' + (mine != null ? ' on' : ''));
b.type = 'button';
b.append(el('span', null, 'Stille'), el('strong', null, mine === true ? 'überspringen' : mine === false ? 'nie überspringen'
: 'wie eingestellt'));
const save = v => { N.podcastFeedSettings(f.id, JSON.stringify({skip_silence: v})); setTimeout(render, 50); };
b.addEventListener('click', () => openSheet(`Stille in „${plain(f.title)}“ …`, [
{icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)',
action: () => save(null)},
{icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', action: () => save(true)},
{icon: mine === false ? '●' : '○', label: 'Nie überspringen', action: () => save(false)},
]));
row.append(b);
}
return row;
}
@ -2081,7 +2287,7 @@
sk.setAttribute('aria-label', sk.title);
}
mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …'
: (player.feed || '') + (player.dur ? ' · ' + clock((player.dur - player.pos) / 1000) + ' übrig' : '');
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen';
@ -2361,7 +2567,7 @@
range.id = 'nui-pod-seek';
range.addEventListener('input', () => {
seeking = true;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value);
sheet.querySelector('.nui-pod-time-pos').textContent = clock(shown(range.value, 0, player.speed || 1));
});
range.addEventListener('change', () => {
N.podcastSeek(range.value * 1000);
@ -2388,7 +2594,8 @@
const b = sheet && sheet.querySelector('.nui-pod-fav');
if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); }
}), 'nui-pod-fav'),
button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'),
button('⏭', keepSkipped() ? 'Nächste Folge (diese bleibt in der Warteschlange)' : 'Folge abschließen, nächste spielen',
finishAndNext, 'nui-pod-next'),
N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'),
button('≡', 'Warteschlange', showQueue),
].filter(Boolean));
@ -2506,11 +2713,13 @@
const dur = Math.round((player.dur || 0) / 1000);
const pos = Math.round((player.pos || 0) / 1000);
range.max = dur || 1;
// At the speed, when so set: the place and what's left as long as they take.
const at = s => shown(s, 0, player.speed || 1);
if (!seeking) {
range.value = pos;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos);
sheet.querySelector('.nui-pod-time-pos').textContent = clock(at(pos));
}
sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : '';
sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(at(dur - pos)) : '';
const p = sheet.querySelector('.nui-pod-play');
p.textContent = player.playing ? '❚❚' : '▶';
p.title = player.playing ? 'Pause' : 'Abspielen';
@ -2530,20 +2739,33 @@
}
// Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast.
// The presets, and the speed now if it isn't one of them (a podcast's own 3,5× …).
function speedChoices(now) {
const list = presets().slice();
if (now > 0 && !list.some(s => Math.abs(s - now) < 0.01)) list.push(now);
return list.sort((a, b) => a - b);
}
function presetsEditItem() {
return 'speed_presets' in settings() ? [{icon: '✎', label: 'Voreinstellungen bearbeiten', hint: 'hinzufügen, entfernen',
action: () => presetsEditor()}] : [];
}
function speedMenu() {
const own = !!player.podcastSpeed;
const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts',
SPEEDS.map(s => ({
speedChoices(own === forPodcast ? player.speed || 1 : 0).map(s => ({
icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○',
label: speedLabel(s),
action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)),
})).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []));
})).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : [])
.concat(presetsEditItem()));
if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; }
openSheet('Geschwindigkeit', [
{icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)},
{icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene',
action: () => pick(true)},
]);
].concat(presetsEditItem()));
}
// ---- sleep timer (runs in the app, also with the screen off) ----
@ -2584,11 +2806,18 @@
openSheet('Sleep-Timer', items);
}
// ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept
// unplayed in the queue, or played. With nothing after it, it only counts as played
// when skipped episodes aren't kept.
function finishAndNext() {
const id = currentId();
if (!id) return;
const ids = queue().map(q => q.episode__id);
const at = ids.indexOf(id);
const last = at < 0 ? !ids.length : at === ids.length - 1;
N.podcastNext();
N.podcastMarkPlayed(id, true);
if (last && !keepSkipped()) N.podcastMarkPlayed(id, true);
else if (last) toast('Das ist die letzte Folge der Warteschlange');
}
function showQueue() {
@ -2767,7 +2996,9 @@
let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {};
// All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round.
let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {};
const DL_COUNTS = [1, 2, 3, 5, 10];
// How many of the queue's first episodes load by themselves; 0 = all of them.
const DL_COUNTS = [1, 2, 3, 5, 10, 0];
const dlCountLabel = n => (n === 0 ? 'Alle' : n === 1 ? '1 Folge' : n + ' Folgen');
const DL_PARALLEL = [1, 2, 3, 4];
// Why a download waits (DownloadPlan.waitReason).
const DL_REASONS = {
@ -2797,7 +3028,9 @@
function dlDelete(e, running) {
N.podcastDeleteDownload(e.id);
toast(running ? 'Download abgebrochen' : 'Download gelöscht');
// "Löschen nimmt aus der Warteschlange" (account): the app takes it out too.
const out = !running && settings().delete_dequeue !== false && 'delete_dequeue' in settings() && inQueue(e.id);
toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht');
}
function dlAppend(row, e) {
@ -2864,9 +3097,11 @@
const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0);
return [
{icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen',
hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})},
s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen',
DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n,
hint: s.auto ? (s.count === 0 ? 'die ganze Warteschlange' : `die ersten ${s.count} Folgen der Warteschlange`) : 'aus',
action: () => dlSet({auto: !s.auto})},
s.auto && {icon: '#', label: 'Wie viele Folgen', hint: dlCountLabel(s.count), action: () => openSheet('Automatisch herunterladen',
DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: dlCountLabel(n), hint: n === 0 ? 'die ganze Warteschlange' : null,
active: s.count === n,
action: () => dlSet({count: n})})))},
s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN',
action: () => dlSet({mobile: !s.mobile})},
@ -3127,9 +3362,19 @@
window.__dioraPodcasts = function (json) {
state = typeof json === 'string' ? parse(json, state) : json;
moveFeedListsToServer();
render();
};
// The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream).
window.__dioraAskStream = function (id, title) {
if (!N.podcastAllowStream) return;
openSheet('Über mobile Daten streamen?', [
{icon: '▶', label: 'Ja, diese Folge streamen', hint: plain(title) || null, action: () => N.podcastAllowStream(id)},
{icon: '✕', label: 'Nein', hint: 'im WLAN oder heruntergeladen hören', action: () => {}},
]);
};
// A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“).
window.__dioraOpenFeed = function (feedId) {
if (typeof showTab !== 'function') return;
@ -3179,6 +3424,7 @@
build();
buildMini();
player = parse(N.playerState(), {kind: null});
moveFeedListsToServer();
render();
renderMini();