Podcasts: "Folge löschen" (episodes/discard/, discarded in state/), Folgen-Seite, sw v69
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 6m52s

- EpisodeProgress.discarded_at (Migration 0015); POST /podcasts/episodes/discard/
  {"episode_ids": [...]}: aus der Warteschlange (_dequeue, auch von queue/remove benutzt)
  und aus "Neu" (dismissed), discarded_at = jetzt; gehört, Stelle, Stern und updated_at
  bleiben; fremde Folgen werden ausgelassen (count)
- state/ liefert "discarded": [{id, at}] der letzten 30 Tage: jedes Gerät löscht seine
  ältere Datei; wieder eingereiht (_enqueue) wird discarded_at gelöscht
- Browser-Host: podcastDiscard (pausiert geladene Folge verlässt den Player, laufende spielt
  zu Ende), podcastPlayEpisodeAt (Kapitel auf der Folgen-Seite)
- static/podcasts aus diora-android ee1a910 (Folgen-Seite, Folge löschen, Datei löschen,
  Wischtexte und Icons, Ziehgriff)
- Tests: DiscardTests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 20:27:24 +02:00
parent 461b42a5bf
commit 985e635f9a
9 changed files with 624 additions and 81 deletions

View file

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0014_playback_routine'),
]
operations = [
migrations.AddField(
model_name='episodeprogress',
name='discarded_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -201,6 +201,10 @@ class EpisodeProgress(models.Model):
played = models.BooleanField(default=False) played = models.BooleanField(default=False)
dismissed = models.BooleanField(default=False) dismissed = models.BooleanField(default=False)
favorite = models.BooleanField(default=False) favorite = models.BooleanField(default=False)
# "Folge löschen": when the user threw the episode away (out of the queue
# and "Neu", not played). Every device deletes its download of it if that
# is older (state/ lists the recent ones); queueing it again clears it.
discarded_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View file

@ -2514,3 +2514,117 @@ class PlaybackSettingsTests(TestCase):
self.client.logout() self.client.logout()
self.assertEqual(self.post({'skip_back': 10}).status_code, 401) self.assertEqual(self.post({'skip_back': 10}).status_code, 401)
self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401) self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401)
class DiscardTests(TestCase):
"""POST /podcasts/episodes/discard/ ("Folge löschen"): out of the queue
and "Neu", the downloads go on every device (state/ "discarded"), but
not played: the place and the star stay."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
self.eps = [
PodcastEpisode.objects.create(feed=feed, guid=f'e{i}', title=f'E{i}', duration_seconds=3600,
audio_url=f'https://example.org/e{i}.mp3', pub_date=base + timedelta(days=i))
for i in range(5)
]
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
self.bob = bob
self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
PodcastQueue.objects.create(user=bob, episode=self.bobs, position=0)
def post(self, body):
return self.client.post('/podcasts/episodes/discard/', json.dumps(body), content_type='application/json')
def queue(self):
return list(PodcastQueue.objects.filter(user=self.user).order_by('position', 'added_at', 'id')
.values_list('episode_id', flat=True))
def state(self):
return self.client.get('/podcasts/state/').json()
def test_out_of_queue_and_inbox_not_played(self):
e0, e1, e2, e3, e4 = self.eps
for i, e in enumerate((e0, e1, e2)):
PodcastQueue.objects.create(user=self.user, episode=e, position=i)
p1 = _progress(self.user, e1, _at(2, 1), position_seconds=754, favorite=True)
self.assertIn(e3.id, [x['id'] for x in self.state()['inbox']])
r = self.post({'episode_ids': [e1.id, e3.id]})
self.assertEqual(r.status_code, 200)
d = r.json()
self.assertEqual((d['ok'], d['count']), (True, 2))
# The rest of the queue keeps its order.
self.assertEqual(self.queue(), [e0.id, e2.id])
# The place, the star and the listening time stay; not played.
p1.refresh_from_db()
self.assertEqual((p1.position_seconds, p1.favorite, p1.played, p1.dismissed), (754, True, False, True))
self.assertEqual(p1.updated_at, _at(2, 1))
self.assertIsNotNone(p1.discarded_at)
# An episode without a row gets one: out of "Neu", not played, no place.
p3 = EpisodeProgress.objects.get(user=self.user, episode=e3)
self.assertEqual((p3.played, p3.dismissed, p3.position_seconds), (False, True, 0))
s = self.state()
inbox = [x['id'] for x in s['inbox']]
self.assertNotIn(e3.id, inbox)
self.assertIn(e4.id, inbox)
self.assertEqual([q['episode__id'] for q in s['queue']], [e0.id, e2.id])
# The state lists both for the other devices, with the time of the discard.
self.assertEqual({x['id'] for x in s['discarded']}, {e1.id, e3.id})
at = next(x['at'] for x in s['discarded'] if x['id'] == e1.id)
self.assertRegex(at, r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
self.assertEqual(at, d['at'])
# Still unplayed in the episode lists, with its place.
unplayed = self.client.get('/podcasts/episodes/', {'filter': 'unplayed'}).json()['episodes']
row = next(x for x in unplayed if x['id'] == e1.id)
self.assertEqual((row['position_seconds'], row['played'], row['in_queue']), (754, False, False))
def test_queued_again_works_and_clears_it(self):
e0 = self.eps[0]
PodcastQueue.objects.create(user=self.user, episode=e0, position=0)
self.post({'episode_ids': [e0.id]})
self.assertEqual(self.queue(), [])
r = self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e0.id]}), content_type='application/json')
self.assertEqual(r.json()['added'], [e0.id])
self.assertEqual(self.queue(), [e0.id])
self.assertIsNone(EpisodeProgress.objects.get(user=self.user, episode=e0).discarded_at)
self.assertEqual(self.state()['discarded'], [])
# Playing it saves its place as ever.
self.client.post('/podcasts/progress/save/', json.dumps({'episode_id': e0.id, 'position_seconds': 99}),
content_type='application/json')
self.assertEqual(EpisodeProgress.objects.get(user=self.user, episode=e0).position_seconds, 99)
def test_kept_queue_order_stays_sorted(self):
PodcastSettings.objects.update_or_create(user=self.user, defaults={'keep_sorted': 'old'})
for e in self.eps:
self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e.id]}), content_type='application/json')
self.assertEqual(self.queue(), [e.id for e in self.eps])
self.post({'episode_ids': [self.eps[2].id]})
self.assertEqual(self.queue(), [self.eps[i].id for i in (0, 1, 3, 4)])
def test_old_discards_leave_the_state(self):
e0, e1 = self.eps[:2]
self.post({'episode_ids': [e0.id, e1.id]})
EpisodeProgress.objects.filter(user=self.user, episode=e0).update(
discarded_at=datetime.now(timezone.utc) - timedelta(days=31))
self.assertEqual([x['id'] for x in self.state()['discarded']], [e1.id])
def test_other_users_episodes_are_left_alone(self):
r = self.post({'episode_ids': [self.bobs.id, 999999]})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['count'], 0)
self.assertTrue(PodcastQueue.objects.filter(user=self.bob, episode=self.bobs).exists())
self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
self.assertEqual(self.state()['discarded'], [])
def test_bad_requests(self):
for body in ({}, {'episode_ids': []}, {'episode_ids': self.eps[0].id}, {'episode_ids': ['x']},
{'episode_ids': [True]}, [1, 2]):
with self.subTest(body=body):
self.assertEqual(self.post(body).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405)
self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401)

View file

@ -16,6 +16,7 @@ urlpatterns = [
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
path('episodes/', views.episode_list, name='podcast_episode_list'), path('episodes/', views.episode_list, name='podcast_episode_list'),
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'), path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'), path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'), path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),

View file

@ -112,10 +112,18 @@ def _enqueue(user, episode_ids, where, current_hint=None):
) )
_write_queue_order(user, queued[:at] + new + queued[at:]) _write_queue_order(user, queued[:at] + new + queued[at:])
_leave_inbox(user, new) _leave_inbox(user, new)
# Wanted again after "Folge löschen": devices keep (or fetch) their download.
EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
_keep_sorted(user) _keep_sorted(user)
return new return new
def _dequeue(user, episode_ids):
"""Take episodes out of the user's queue. The others keep their order
(a queue kept sorted stays sorted: taking some out doesn't change that)."""
PodcastQueue.objects.filter(user=user, episode_id__in=episode_ids).delete()
def _sort_key(rule): def _sort_key(rule):
"""Sort key for one queue row ({pub_date, duration, position, feed_title}) """Sort key for one queue row ({pub_date, duration, position, feed_title})
by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order
@ -1570,8 +1578,9 @@ def refresh_all_feeds(request):
def podcast_state(request): def podcast_state(request):
"""Everything a client shows first, in one response: feeds, queue, the """Everything a client shows first, in one response: feeds, queue, the
first page of the inbox (inbox_limit of inbox_total new episodes), the first page of the inbox (inbox_limit of inbox_total new episodes), the
settings and how many AntennaPod downloads wait to be offered settings, how many AntennaPod downloads wait to be offered
(import-downloads/).""" (import-downloads/) and the episodes discarded lately ("Folge löschen":
a device deletes its older download of 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)
try: try:
@ -1588,6 +1597,7 @@ def podcast_state(request):
'inbox_limit': limit, 'inbox_limit': limit,
'settings': _settings_data(s), 'settings': _settings_data(s),
'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)},
'discarded': _discarded_data(request.user),
'server_time': timezone.now().isoformat(), 'server_time': timezone.now().isoformat(),
}) })
@ -1680,8 +1690,7 @@ def queue_remove(request):
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400) return JsonResponse({'error': 'invalid JSON'}, status=400)
episode_id = body.get('episode_id') _dequeue(request.user, [body.get('episode_id')])
PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete()
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})
@ -1940,6 +1949,62 @@ def dismiss_episodes(request):
return JsonResponse({'ok': True, 'count': len(valid_ids)}) return JsonResponse({'ok': True, 'count': len(valid_ids)})
# ---------------------------------------------------------------------------
# "Folge löschen": out of the queue and "Neu", its downloads gone everywhere
# ---------------------------------------------------------------------------
DISCARDED_DAYS = 30 # how long state/ lists a discard for devices still to delete their copy
def _discarded_data(user, now=None):
"""[{"id": episode id, "at": ISO ms}] of the episodes discarded lately:
a device deletes its download of one if it finished before `at`."""
since = (now or timezone.now()) - timedelta(days=DISCARDED_DAYS)
rows = (EpisodeProgress.objects.filter(user=user, discarded_at__gte=since)
.order_by('discarded_at').values_list('episode_id', 'discarded_at'))
return [{'id': ep_id, 'at': _iso_ms(at)} for ep_id, at in rows]
@csrf_exempt
@require_http_methods(['POST'])
def discard_episodes(request):
"""{"episode_ids": [...]}: the episodes leave the queue and "Neu" (like
dismiss: not played) and every device deletes its download of them (they
see them under "discarded" in state/). Played, the saved place and the
star stay as they are; the listening time (updated_at) too. Episodes that
aren't the user's are left out (count)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
if not isinstance(body, dict):
return JsonResponse({'error': 'invalid JSON'}, status=400)
episode_ids = body.get('episode_ids')
if not isinstance(episode_ids, list) or not episode_ids:
return JsonResponse({'error': 'episode_ids (a list) required'}, status=400)
if not all(isinstance(i, int) and not isinstance(i, bool) for i in episode_ids):
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
wanted = set(episode_ids[:EPISODE_IDS_MAX])
valid = sorted(
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
)
now = timezone.now()
with transaction.atomic():
_dequeue(request.user, valid)
# update() leaves updated_at alone: throwing an episode away isn't listening to it.
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
rows.update(dismissed=True, discarded_at=now)
have = set(rows.values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=request.user, episode_id=i, dismissed=True, discarded_at=now)
for i in valid if i not in have],
ignore_conflicts=True,
)
return JsonResponse({'ok': True, 'count': len(valid), 'at': _iso_ms(now)})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Inbox # Inbox
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -357,6 +357,23 @@
}); });
} }
// "Folge löschen": out of the queue and "Neu", not played (its place stays). The
// server lists it under "discarded" so the devices with downloads delete theirs
// (the browser has none). In the player here: paused, it leaves the player (see
// dropDiscarded); playing, it goes on until it ends or another one starts.
function discard(ids) {
if (!ids.length) return;
edit(s => {
const set = new Set(ids);
const order = queueIds(s);
if (order.some(id => set.has(id))) setQueueOrder(s, order.filter(id => !set.has(id)));
markInQueue(s, set, false);
removeFromInbox(s, set);
queueOp('/podcasts/episodes/discard/', {episode_ids: ids});
});
dropDiscarded(ids);
}
function setSettings(changes) { function setSettings(changes) {
edit(s => { edit(s => {
const settings = s.settings || (s.settings = {}); const settings = s.settings || (s.settings = {});
@ -742,12 +759,43 @@
if (n) goTo(n, false, wantPlay, true); if (n) goTo(n, false, wantPlay, true);
} }
// A discarded episode loaded here and paused leaves the player: the queue's first one
// takes its place (loaded, not playing), or nothing. Its place was kept on pause.
function dropDiscarded(ids) {
const id = curId();
if (id == null || ids.indexOf(id) < 0 || wantPlay) return;
const n = queueOf(state)[0] || null;
if (!n) { putAway(); syncSoon(); return; }
unload();
cur = Object.assign({}, n);
startAt = null;
pausedAt = 0;
buffering = false;
error = null;
lastDur = 0;
lastPos = resumeMs();
storePlayer();
report();
syncSoon();
}
function playEpisode(id) { function playEpisode(id) {
if (curId() === id) { resume(); return; } if (curId() === id) { resume(); return; }
const e = entry(id); const e = entry(id);
if (e) goTo(e, false, true); if (e) goTo(e, false, true);
} }
// From `ms` on (the episode screen: a chapter, a time in the shownotes).
function playEpisodeAt(id, ms) {
if (curId() === id) { seek(ms); resume(); return; }
const e = entry(id);
if (!e) return;
goTo(e, false, false);
startAt = ms;
lastPos = ms;
resume();
}
// The podcast's outro is skipped: the episode counts as finished there. // The podcast's outro is skipped: the episode counts as finished there.
function skipOutro(id) { function skipOutro(id) {
if (outroDone === id) return; if (outroDone === id) return;
@ -1512,6 +1560,7 @@
}, },
podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)), podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)),
podcastDismiss: sent(json => dismiss(ids(json))), podcastDismiss: sent(json => dismiss(ids(json))),
podcastDiscard: sent(json => discard(ids(json))),
podcastSettings: sent(json => setSettings(parse(json, {}) || {})), podcastSettings: sent(json => setSettings(parse(json, {}) || {})),
podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)), podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)),
podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})), podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})),
@ -1529,6 +1578,13 @@
playEpisode(id); playEpisode(id);
flush(); flush();
}, },
podcastPlayEpisodeAt(json, currentId, ms) {
const ep = parse(json, {}) || {};
const id = ep.episode__id != null ? ep.episode__id : ep.id;
if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null);
playEpisodeAt(id, Math.max(0, Number(ms) || 0));
flush();
},
podcastNext: () => next(), podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()), podcastToggle: () => (wantPlay ? pause() : resume()),
podcastSeek: ms => seek(Number(ms)), podcastSeek: ms => seek(Number(ms)),

View file

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

View file

@ -1,4 +1,4 @@
// Copied from diora-android 339de8f 2026-10-02: web-native/static/native-podcast.js // Copied from diora-android ee1a910 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.
@ -135,8 +135,33 @@
return b; return b;
} }
// Line icons in the text's colour (black on e-ink): deleting is a bin, taking out of a
// list a list with an x — the same in menus, swipes, the selection bar and the undo bar.
const SVG_PATHS = {
trash: '<path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l1 12.5h9l1-12.5M10 11v5.5M14 11v5.5"/>',
listx: '<path d="M4 6h11M4 11h7M4 16h6M14.5 13.5l5.5 5.5M20 13.5l-5.5 5.5"/>',
};
function svgIcon(name, size) {
const span = el('span', 'nui-svg');
const px = size || 20;
span.innerHTML = `<svg viewBox="0 0 24 24" width="${px}" height="${px}" aria-hidden="true" fill="none" ` +
`stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SVG_PATHS[name]}</svg>`;
return span;
}
// The sheets (native-ui.js, or the browser host's copy) show text icons; an item's `svg`
// replaces its icon afterwards (the rows come in the items' order).
function openSheet(title, items) { function openSheet(title, items) {
if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items); if (!(window.DioraUI && DioraUI.openSheet)) return;
DioraUI.openSheet(title, items);
const shown = items.filter(it => it && !it.custom);
if (!shown.some(it => it.svg)) return;
const rows = document.querySelectorAll('#nui-sheet .nui-panel > .nui-row');
if (rows.length !== shown.length) return;
shown.forEach((it, i) => {
const icon = it.svg && rows[i].querySelector('.nui-row-icon');
if (icon) { icon.textContent = ''; icon.append(svgIcon(it.svg)); }
});
} }
function clock(seconds) { function clock(seconds) {
@ -281,18 +306,58 @@
const selectItem = (e, opts) => opts && opts.select && const selectItem = (e, opts) => opts && opts.select &&
{icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)}; {icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)};
// „Folge löschen“: out of the queue and „Neu“, its download goes here and on every other
// device (the server tells them), but not played: it keeps its place and stays in „Folgen“.
const DISCARD_HINT = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört';
const discardItem = (e, opts) => N.podcastDiscard &&
{icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: DISCARD_HINT, action: () => discardOne(e, (opts && opts.list) || '', (opts && opts.filters) || [])};
// One episode: no question, 4 s „Rückgängig“ like a swipe; sent only then. False when its row leaves the list.
function discardOne(e, list, filters) {
const hide = list === 'queue' || list === 'inbox' || list === 'downloads' ||
filters.includes('downloaded') || filters.includes('queued');
offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => {
discardNow([e.id]);
if (!hide) render();
}});
return !hide;
}
function discardNow(ids) {
N.podcastDiscard(JSON.stringify(ids));
// The lists fetched from the server (Folgen, a podcast) show it at once.
const lists = [epList && epList.items, feedView && feedView.episodes];
for (const l of lists) for (const o of l || []) if (ids.includes(o.id)) o.in_queue = false;
}
// Several: asked first.
function discardMany(ids) {
openSheet(`${nEpisodes(ids.length)} löschen? Nicht als gehört markiert.`, [
{icon: '⊠', svg: 'trash', label: 'Löschen', hint: DISCARD_HINT, danger: true, action: () => {
discardNow(ids);
toast(`${nEpisodes(ids.length)} gelöscht`);
stopSelecting();
}},
{icon: '‹', label: 'Abbrechen', action: () => {}},
]);
}
const discardAction = () => N.podcastDiscard && {label: 'Löschen', svg: 'trash', danger: true, run: discardMany};
function queueMenu(e, opts) { function queueMenu(e, opts) {
const playing = currentId() === e.id; const playing = currentId() === e.id;
openSheet(e.title, [ openSheet(e.title, [
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)}, {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
!playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, !playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e), favoriteItem(e),
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
{icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)}, {icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)},
...dlMenuItems(e), ...dlMenuItems(e),
{icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)}, {icon: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
action: () => N.podcastDequeue(e.id)},
discardItem(e, opts),
selectItem(e, opts), selectItem(e, opts),
]); ]);
} }
@ -301,17 +366,19 @@
const queued = inQueue(e.id); const queued = inQueue(e.id);
openSheet(e.title, [ openSheet(e.title, [
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
{icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e), favoriteItem(e),
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')}, !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')}, !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)}, queued && {icon: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
action: () => N.podcastDequeue(e.id)},
!e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)}, !e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)},
e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)}, e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
...dlMenuItems(e), ...dlMenuItems(e),
{icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen', {icon: '⊘', svg: 'listx', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
action: () => N.podcastDismiss(JSON.stringify([e.id]))}, action: () => N.podcastDismiss(JSON.stringify([e.id]))},
discardItem(e, opts),
selectItem(e, opts), selectItem(e, opts),
]); ]);
} }
@ -444,8 +511,8 @@
// AntennaPod's "delete removes from queue". // AntennaPod's "delete removes from queue".
function deleteDequeueItem() { function deleteDequeueItem() {
if (!DL) return null; if (!DL) return null;
return accountSwitch('delete_dequeue', 'Löschen nimmt aus der Warteschlange', return accountSwitch('delete_dequeue', '„Datei 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); 'an: wer die Datei einer Folge löscht, nimmt sie auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true);
} }
// AntennaPod's "adjust media info to playback speed". // AntennaPod's "adjust media info to playback speed".
@ -681,6 +748,7 @@
const acts = el('div', 'nui-pod-selactions'); const acts = el('div', 'nui-pod-selactions');
for (const a of actions.filter(Boolean)) { for (const a of actions.filter(Boolean)) {
const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label); const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
if (a.svg) b.prepend(svgIcon(a.svg, 16));
b.type = 'button'; b.type = 'button';
b.disabled = !selected.size; b.disabled = !selected.size;
b.addEventListener('click', () => { b.addEventListener('click', () => {
@ -715,12 +783,13 @@
{icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)}, {icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)},
])}, ])},
// Only where some of them are new. // Only where some of them are new.
items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', run: ids => { items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', svg: 'listx', run: ids => {
const gone = ids.filter(id => fresh.has(id)); const gone = ids.filter(id => fresh.has(id));
if (gone.length) N.podcastDismiss(JSON.stringify(gone)); if (gone.length) N.podcastDismiss(JSON.stringify(gone));
toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“'); toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“');
stopSelecting(); stopSelecting();
}}, }},
discardAction(),
].filter(Boolean)); ].filter(Boolean));
} }
@ -913,6 +982,7 @@
} }
function render() { function render() {
episodeScreenParts();
if (!root) return; if (!root) return;
const scroll = scrollTop(); const scroll = scrollTop();
// Search results arriving while typing: the field keeps its text and the cursor. // Search results arriving while typing: the field keeps its text and the cursor.
@ -1057,9 +1127,10 @@
row.addEventListener('click', () => toggleSelected(e.id)); row.addEventListener('click', () => toggleSelected(e.id));
return row; return row;
} }
const menu = () => (list === 'queue' ? queueMenu(e, {select: !opts.noSelect}) : episodeMenu(e, {select: !opts.noSelect})); const how = {select: !opts.noSelect, list, filters: opts.filters || []};
const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how));
row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu')); row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu'));
row.addEventListener('click', () => play(e)); row.addEventListener('click', () => openEpisode(e, how));
// Swipes: touch only, not while the queue is locked. // Swipes: touch only, not while the queue is locked.
const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : ''; const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : '';
rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes); rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes);
@ -1128,8 +1199,22 @@
bg.dataset.dir = d; bg.dataset.dir = d;
bg.classList.toggle('armed', armed()); bg.classList.toggle('armed', armed());
const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r'); const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r');
label.textContent = act === 'none' ? '' : (d === 'right' ? `${a.icon} ${swipeLabel(a, row)}` : `${swipeLabel(a, row)} ${a.icon}`); const other = bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l');
bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l').textContent = ''; if (label.dataset.act !== act) {
label.dataset.act = act;
label.textContent = '';
if (act !== 'none') {
const t = swipeText(a, row);
const text = el('span', 'nui-pod-swipe-text');
text.append(el('b', null, t.title));
if (t.hint) text.append(el('small', null, t.hint));
const icon = a.svg ? svgIcon(a.svg, 22) : el('span', null, a.icon);
icon.classList.add('nui-pod-swipe-icon');
label.append(icon, text);
}
}
other.textContent = '';
delete other.dataset.act;
}; };
const finish = done => { const finish = done => {
row.classList.remove('nui-pod-swiping'); row.classList.remove('nui-pod-swiping');
@ -1144,14 +1229,17 @@
} }
}; };
row.addEventListener('pointerdown', ev => { row.addEventListener('pointerdown', ev => {
if (ev.target.closest('.nui-pod-handle') || (ev.pointerType === 'mouse' && ev.button !== 0)) return; if (ev.pointerType === 'mouse' && ev.button !== 0) return;
// On the queue's drag handle: a vertical move sorts (dragSort), a sideways one is the swipe.
const onHandle = !!ev.target.closest('.nui-pod-handle');
if (onHandle && !onSwipe) return;
start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'}; start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'};
mode = ''; mode = '';
dx = vx = 0; dx = vx = 0;
last = {x: ev.clientX, t: ev.timeStamp}; last = {x: ev.clientX, t: ev.timeStamp};
swallow = false; swallow = false;
stopTimer(); stopTimer();
if (!ev.target.closest('button')) { if (!onHandle && !ev.target.closest('button')) {
timer = setTimeout(() => { timer = setTimeout(() => {
timer = 0; timer = 0;
if (mode || !start) return; if (mode || !start) return;
@ -1230,9 +1318,10 @@
row.addEventListener('contextmenu', ev => ev.preventDefault()); row.addEventListener('contextmenu', ev => ev.preventDefault());
} }
// What swiping does, per list (this device). The defaults are an AntennaPod setup. // What swiping does, per list (this device). The defaults are an AntennaPod setup, except the
// queue's left: „Folge löschen“ where the host can (it was „Datei löschen“).
const SWIPE_LISTS = { const SWIPE_LISTS = {
queue: {label: 'Warteschlange', left: 'delete', right: 'dequeue'}, queue: {label: 'Warteschlange', left: N.podcastDiscard ? 'discard' : 'delete', right: 'dequeue'},
inbox: {label: 'Neu', left: 'dismiss', right: 'queue'}, inbox: {label: 'Neu', left: 'dismiss', right: 'queue'},
episodes: {label: 'Folgen', left: 'download', right: 'favorite'}, episodes: {label: 'Folgen', left: 'download', right: 'favorite'},
feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'}, feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'},
@ -1241,13 +1330,19 @@
const SWIPE_ACTIONS = [ const SWIPE_ACTIONS = [
{id: 'none', label: 'Nichts', icon: ''}, {id: 'none', label: 'Nichts', icon: ''},
{id: 'queue', label: 'Einreihen', icon: '+'}, {id: 'queue', label: 'Einreihen', icon: '+'},
{id: 'dequeue', label: 'Aus der Warteschlange', icon: '✕'}, // Where there are files (DL is known only further down: getters).
{id: 'dequeue', get label() { return DL ? 'Aus Warteschlange · Datei bleibt' : 'Aus der Warteschlange'; },
icon: '✕', svg: 'listx'},
{id: 'download', label: 'Herunterladen', icon: '↓', dl: true}, {id: 'download', label: 'Herunterladen', icon: '↓', dl: true},
{id: 'delete', label: 'Download löschen', icon: '✕', dl: true}, {id: 'delete', label: 'Datei löschen', hint: 'nur die Datei auf diesem Gerät', icon: '✕', svg: 'trash', dl: true},
{id: 'played', label: 'Gehört / ungehört', icon: '✓'}, {id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'},
{id: 'favorite', label: 'Favorit an / aus', icon: '★'}, {id: 'favorite', label: 'Favorit an / aus', icon: '★'},
{id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘'}, {id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘', svg: 'listx'},
{id: 'discard', label: 'Folge löschen', hint: 'aus Warteschlange und Neu, Datei auf allen Geräten weg – nicht gehört',
icon: '⊠', svg: 'trash', host: 'podcastDiscard'},
]; ];
// An action the host can do (the browser has no downloads; an older host no „Folge löschen“).
const canSwipe = a => (!a.dl || DL) && (!a.host || !!N[a.host]);
function swipePrefs() { function swipePrefs() {
let all = {}; let all = {};
@ -1255,17 +1350,46 @@
return all; return all;
} }
// The queue's left swipe was „Download löschen“ by default: kept as such (stored when chosen),
// it becomes „Folge löschen“ once; any other choice stays.
(function newQueueDefault() {
if (!N.podcastDiscard) return;
const all = swipePrefs();
if (all.v >= 2) return;
if (all.queue && all.queue.left === 'delete') all.queue.left = 'discard';
all.v = 2;
try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {}
})();
function swipeAction(list, dir) { function swipeAction(list, dir) {
const l = SWIPE_LISTS[list]; const l = SWIPE_LISTS[list];
if (!l) return 'none'; if (!l) return 'none';
const own = (swipePrefs()[list] || {})[dir]; const own = (swipePrefs()[list] || {})[dir];
const id = own || l[dir]; const id = own || l[dir];
const a = SWIPE_ACTIONS.find(x => x.id === id); const a = SWIPE_ACTIONS.find(x => x.id === id);
return a && (!a.dl || DL) ? id : 'none'; return a && canSwipe(a) ? id : 'none';
} }
function swipeLabel(a) { // What the swipe says behind the moving row: the action by its full name (bold) and what
return {played: 'Gehört', favorite: 'Favorit', dequeue: 'Entfernen', delete: 'Löschen', dismiss: 'Aus „Neu“'}[a.id] || a.label; // it means for this episode (small) — never a bare „Löschen“ or „Entfernen“.
function swipeText(a, row) {
const id = row && Number(row.dataset.id);
switch (a.id) {
case 'discard': return {title: 'Folge löschen', hint: 'nicht gehört'};
case 'delete': return {title: 'Datei löschen', hint: id && deleteDequeues(id) ? 'auch aus Warteschlange' : 'nur auf diesem Gerät'};
case 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null};
case 'dismiss': return {title: 'Aus Neu entfernen', hint: 'nicht gehört'};
case 'queue': return {title: 'Einreihen'};
case 'download': return {title: 'Laden'};
case 'favorite': return {title: 'Favorit'};
case 'played': return {title: row && row.classList.contains('nui-pod-played') ? 'Ungehört' : 'Gehört'};
default: return {title: a.label};
}
}
// „Datei löschen“ takes the episode out of the queue too (the account's delete_dequeue, on by default).
function deleteDequeues(id) {
return 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(id);
} }
function swipeSettings() { function swipeSettings() {
@ -1280,9 +1404,9 @@
function swipeListSheet(list) { function swipeListSheet(list) {
const l = SWIPE_LISTS[list]; const l = SWIPE_LISTS[list];
const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`, const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`,
SWIPE_ACTIONS.filter(a => !a.dl || DL).map(a => ({ SWIPE_ACTIONS.filter(canSwipe).map(a => ({
icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id, icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id,
hint: a.id === l[dir] ? 'Standard' : null, hint: [a.hint, a.id === l[dir] ? 'Standard' : null].filter(Boolean).join(' · ') || null,
action: () => { action: () => {
const all = swipePrefs(); const all = swipePrefs();
all[list] = Object.assign({}, all[list], {[dir]: a.id}); all[list] = Object.assign({}, all[list], {[dir]: a.id});
@ -1332,13 +1456,13 @@
} }
// The bar with „Rückgängig“ (the toast can't be tapped). // The bar with „Rückgängig“ (the toast can't be tapped).
function undoBar(text) { function undoBar(text, svg) {
let bar = $id('nui-pod-undo'); let bar = $id('nui-pod-undo');
if (!text) { if (bar) bar.classList.remove('nui-show'); return; } if (!text) { if (bar) bar.classList.remove('nui-show'); return; }
if (!bar) { if (!bar) {
bar = el('div'); bar = el('div');
bar.id = 'nui-pod-undo'; bar.id = 'nui-pod-undo';
bar.append(el('span', 'nui-pod-undo-text')); bar.append(el('span', 'nui-pod-undo-icon'), el('span', 'nui-pod-undo-text'));
const b = el('button', 'nui-pod-undo-btn', 'Rückgängig'); const b = el('button', 'nui-pod-undo-btn', 'Rückgängig');
b.type = 'button'; b.type = 'button';
b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); }); b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); });
@ -1348,6 +1472,10 @@
const t = $id('nui-radio-toast'); const t = $id('nui-radio-toast');
if (t) t.classList.remove('nui-show'); if (t) t.classList.remove('nui-show');
bar.querySelector('.nui-pod-undo-text').textContent = text; bar.querySelector('.nui-pod-undo-text').textContent = text;
const icon = bar.querySelector('.nui-pod-undo-icon');
icon.textContent = '';
if (svg) icon.append(svgIcon(svg, 18));
icon.hidden = !svg;
bar.classList.add('nui-show'); bar.classList.add('nui-show');
} }
@ -1355,7 +1483,7 @@
commitSwipe(); commitSwipe();
pendingSwipe = Object.assign({id: e.id}, change); pendingSwipe = Object.assign({id: e.id}, change);
pendingSwipe.timer = setTimeout(commitSwipe, 4000); pendingSwipe.timer = setTimeout(commitSwipe, 4000);
undoBar(text); undoBar(text, change.svg);
if (change.hide) render(); if (change.hide) render();
} }
@ -1377,7 +1505,8 @@
if (act === 'dequeue') { if (act === 'dequeue') {
if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; } if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; }
const hide = list === 'queue'; const hide = list === 'queue';
offerUndo(e, 'Aus der Warteschlange genommen', {hide, run: () => { N.podcastDequeue(e.id); o.in_queue = false; }}); offerUndo(e, dlDone(e.id) ? 'Aus Warteschlange · Datei bleibt' : 'Aus Warteschlange',
{hide, svg: 'listx', run: () => { N.podcastDequeue(e.id); o.in_queue = false; }});
return !hide; return !hide;
} }
if (act === 'download') { if (act === 'download') {
@ -1394,9 +1523,10 @@
if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; } if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; }
const running = !has('done') && !has('failed'); const running = !has('done') && !has('failed');
// "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too. // "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too.
const out = !running && 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(e.id); const out = !running && deleteDequeues(e.id);
const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out); const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out);
offerUndo(e, running ? 'Download abgebrochen' : 'Download gelöscht', {hide, run: () => dlDelete(e, running, true)}); offerUndo(e, running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht',
{hide, svg: running ? null : 'trash', run: () => dlDelete(e, running, true)});
return !hide; return !hide;
} }
if (act === 'played') { if (act === 'played') {
@ -1424,9 +1554,10 @@
// „Neu“ is every unplayed episode not taken out (the page knows only the first ones). // „Neu“ is every unplayed episode not taken out (the page knows only the first ones).
if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; } if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; }
const hide = list === 'inbox'; const hide = list === 'inbox';
offerUndo(e, 'Aus „Neu“ entfernt', {hide, run: () => N.podcastDismiss(JSON.stringify([e.id]))}); offerUndo(e, 'Aus Neu entfernt', {hide, svg: 'listx', run: () => N.podcastDismiss(JSON.stringify([e.id]))});
return !hide; return !hide;
} }
if (act === 'discard' && N.podcastDiscard) return discardOne(e, list, filters);
return true; return true;
} }
@ -1442,7 +1573,8 @@
{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)))))},
{label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))}, {label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Entfernen', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, {label: 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
discardAction(),
])); ]));
} else { } else {
const head = el('div', 'nui-pod-head'); const head = el('div', 'nui-pod-head');
@ -1497,8 +1629,9 @@
{icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')}, {icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')},
])}, ])},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }}, {label: 'Ausblenden', svg: 'listx', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))}, DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))},
discardAction(),
])); ]));
} else { } else {
const head = el('div', 'nui-pod-head'); const head = el('div', 'nui-pod-head');
@ -2571,19 +2704,37 @@
// ---- drag to reorder (touch) ----------------------------------------------------------- // ---- drag to reorder (touch) -----------------------------------------------------------
// Dragging the handle (≡) sorts the queue — once the move is mostly vertical (after the same
// 12 px the swipe waits for); a mostly sideways one is left to the row's swipe (rowGestures).
function dragSort(handle, row, list) { function dragSort(handle, row, list) {
handle.addEventListener('pointerdown', ev => { handle.addEventListener('pointerdown', ev => {
ev.preventDefault(); if (ev.pointerType === 'mouse' && ev.button !== 0) return;
ev.stopPropagation(); if (ev.pointerType === 'mouse') ev.preventDefault(); // no text selection while dragging
handle.setPointerCapture(ev.pointerId); const id = ev.pointerId;
const rows = [...list.children]; const startX = ev.clientX;
const from = rows.indexOf(row);
const rects = rows.map(r => r.getBoundingClientRect());
const startY = ev.clientY; const startY = ev.clientY;
let to = from; let rows = null;
let from = -1;
let rects = null;
let to = -1;
const begin = () => {
rows = [...list.children];
from = rows.indexOf(row);
rects = rows.map(r => r.getBoundingClientRect());
to = from;
row.classList.add('nui-pod-dragging'); row.classList.add('nui-pod-dragging');
try { handle.setPointerCapture(id); } catch (e) {}
};
const move = e => { const move = e => {
if (e.pointerId !== id) return;
const dy = e.clientY - startY; const dy = e.clientY - startY;
if (!rows) {
const dx = e.clientX - startX;
if (Math.abs(dx) <= 12 && Math.abs(dy) <= 12) return;
if (Math.abs(dy) <= Math.abs(dx)) { stop(); return; }
begin();
}
e.preventDefault();
row.style.transform = `translateY(${dy}px)`; row.style.transform = `translateY(${dy}px)`;
const mid = rects[from].top + rects[from].height / 2 + dy; const mid = rects[from].top + rects[from].height / 2 + dy;
to = rects.findIndex(r => mid < r.top + r.height / 2); to = rects.findIndex(r => mid < r.top + r.height / 2);
@ -2596,10 +2747,16 @@
r.style.transform = shift ? `translateY(${shift}px)` : ''; r.style.transform = shift ? `translateY(${shift}px)` : '';
}); });
}; };
const end = () => { // Listened to on the window: before the drag starts the finger may leave the narrow handle.
handle.removeEventListener('pointermove', move); const stop = () => {
handle.removeEventListener('pointerup', end); window.removeEventListener('pointermove', move);
handle.removeEventListener('pointercancel', end); window.removeEventListener('pointerup', end);
window.removeEventListener('pointercancel', end);
};
const end = e => {
if (e.pointerId !== id) return;
stop();
if (!rows) return;
rows.forEach(r => { r.style.transform = ''; }); rows.forEach(r => { r.style.transform = ''; });
row.classList.remove('nui-pod-dragging'); row.classList.remove('nui-pod-dragging');
if (to !== from) { if (to !== from) {
@ -2608,9 +2765,9 @@
N.podcastMove(parseInt(row.dataset.id, 10), to); N.podcastMove(parseInt(row.dataset.id, 10), to);
} }
}; };
handle.addEventListener('pointermove', move); window.addEventListener('pointermove', move);
handle.addEventListener('pointerup', end); window.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end); window.addEventListener('pointercancel', end);
}); });
} }
@ -2806,8 +2963,9 @@
} }
} }
// Chapters and shownotes into a box; with `live`, taps jump in the playing episode. // Chapters and shownotes into a box; with `live`, taps jump in the playing episode,
async function showDetails(box, id, live) { // else with `jump(seconds)` (the episode screen) they start it there.
async function showDetails(box, id, live, jump) {
box.textContent = ''; box.textContent = '';
box.dataset.episode = String(id); box.dataset.episode = String(id);
let d = storedDetails(id); let d = storedDetails(id);
@ -2841,6 +2999,7 @@
li.dataset.index = String(i); li.dataset.index = String(i);
li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`))); li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`)));
if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000))); if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000)));
else if (jump) li.addEventListener('click', () => jump(c.start || 0));
ol.append(li); ol.append(li);
}); });
body.append(ol); body.append(ol);
@ -2851,13 +3010,14 @@
const notes = el('div', 'nui-pod-notes'); const notes = el('div', 'nui-pod-notes');
notes.innerHTML = d.description || '<p>Keine Shownotes.</p>'; notes.innerHTML = d.description || '<p>Keine Shownotes.</p>';
notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank')); notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank'));
if (live) { if (live || jump) {
linkStamps(notes); linkStamps(notes);
notes.addEventListener('click', ev => { notes.addEventListener('click', ev => {
const a = ev.target.closest('a.nui-pod-stamp'); const a = ev.target.closest('a.nui-pod-stamp');
if (!a) return; if (!a) return;
ev.preventDefault(); ev.preventDefault();
N.podcastSeek(parseInt(a.dataset.t, 10) * 1000); if (live) N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
else jump(parseInt(a.dataset.t, 10));
}); });
} }
body.append(notes); body.append(notes);
@ -2987,34 +3147,124 @@
} }
} }
// Shownotes of an episode that isn't playing: its own sheet. // ---- the episode screen: a tap on a row opens it ----------------------------------------
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
let notesSheet = null; let notesSheet = null;
let epScreen = null; // {e, ctx, actions, playBtn} while the episode screen is open
function openNotes(e) { function openEpisode(e, ctx) {
closeNotes(); closeNotes();
ctx = ctx || {};
notesSheet = el('div'); notesSheet = el('div');
notesSheet.id = 'nui-pod-player'; notesSheet.id = 'nui-pod-player';
notesSheet.className = 'nui-pod-notes-sheet'; notesSheet.className = 'nui-pod-notes-sheet nui-pod-episode';
const panel = el('div', 'nui-pod-player-panel'); const panel = el('div', 'nui-pod-player-panel');
const top = el('div', 'nui-pod-player-top'); const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Schließen', closeNotes)); top.append(button('⌄', 'Schließen', closeNotes));
panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || '')); const head = el('div', 'nui-pod-ephead');
const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen'); head.append(art(e.art, 'big', e.feedArt, e.feed));
play1.type = 'button'; const info = el('div', 'nui-pod-main');
play1.addEventListener('click', () => { closeNotes(); play(e); }); info.append(el('div', 'nui-pod-player-title', e.title));
panel.append(play1); const f = e.feedId ? feeds().find(x => x.id === e.feedId) : null;
if (f) {
const link = el('button', 'nui-pod-eplink', plain(f.title) + ' ›');
link.type = 'button';
link.title = 'Zur Podcast-Seite';
link.addEventListener('click', () => {
closeNotes();
closePlayer();
preview = null;
if (typeof showTab === 'function') showTab('podcasts');
openFeed(f);
});
info.append(link);
} else if (e.feed) {
info.append(el('div', 'nui-pod-player-feed', e.feed));
}
info.append(el('div', 'nui-pod-meta nui-pod-epfacts'));
head.append(info);
const playBtn = el('button', 'btn nui-pod-notes-play nui-pod-epplay');
playBtn.type = 'button';
playBtn.addEventListener('click', () => {
if (currentId() === e.id) { N.podcastToggle(); return; }
closeNotes();
play(e);
});
const actions = el('div', 'nui-pod-epactions');
const box = el('div', 'nui-pod-notesbox'); const box = el('div', 'nui-pod-notesbox');
panel.append(box); panel.append(top, head, playBtn, actions, box);
notesSheet.append(panel); notesSheet.append(panel);
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
document.body.append(notesSheet); document.body.append(notesSheet);
showDetails(box, e.id, false); epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
episodeScreenParts();
showDetails(box, e.id, false, seconds => playAt(e, seconds));
}
// Start the episode at `seconds` (a chapter, a time in the notes).
function playAt(e, seconds) {
const ms = Math.round(seconds * 1000);
if (currentId() === e.id) {
N.podcastSeek(ms);
if (!player.playing) N.podcastToggle();
} else if (N.podcastPlayEpisodeAt) {
N.podcastPlayEpisodeAt(JSON.stringify(e.raw), currentId(), ms);
} else {
play(e);
}
closeNotes();
}
// The parts of the screen that follow the state: facts, the play button, the actions.
function episodeScreenParts() {
if (!epScreen || !notesSheet) return;
const {e, ctx, actions, playBtn, facts} = epScreen;
// The freshest place and queue state of the episode.
const q = queue().find(x => x.episode__id === e.id);
const pos = q ? q.position_seconds || 0 : e.raw.position_seconds || 0;
const played = !!e.raw.played;
const dur = e.duration || 0;
const parts = [];
if (e.date) parts.push(day(e.date));
if (dur) parts.push(minutes(shown(dur, e.feedId)));
if (played) parts.push('gehört');
else if (pos > 30 && dur) parts.push('noch ' + minutes(shown(Math.max(0, dur - pos), e.feedId)));
facts.textContent = parts.join(' · ');
const now = currentId() === e.id;
playBtn.textContent = now && player.playing ? '❚❚ Pause'
: !played && pos > 5 ? `▶ Weiter ab ${clock(pos)}` : '▶ Abspielen';
actions.textContent = '';
const act = (svg, icon, label, run, cls) => {
const b = el('button', 'nui-pod-epact' + (cls ? ' ' + cls : ''));
b.type = 'button';
b.append(svg ? svgIcon(svg, 22) : el('span', 'nui-pod-epact-icon', icon), el('span', null, label));
b.addEventListener('click', ev => { ev.stopPropagation(); run(); setTimeout(episodeScreenParts, 150); });
actions.append(b);
};
if (inQueue(e.id)) act('listx', '', 'Aus Warteschlange', () => { N.podcastDequeue(e.id); e.raw.in_queue = false; });
else act(null, '+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; });
if (DL && (e.raw.audio_url || e.raw.episode__audio_url)) {
const d = downloads[e.id];
if (!d || d.state === 'failed') act(null, '↓', 'Laden', () => dlStart(e));
else if (d.state === 'done') act('trash', '', 'Datei löschen', () => dlDelete(e));
else act('trash', '', 'Download abbrechen', () => dlDelete(e, true));
}
if (N.podcastDiscard) {
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
}
const fav = e.raw.favorite;
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
fav ? 'on' : '');
if (played) act(null, '↺', 'Ungehört', () => { N.podcastMarkPlayed(e.id, false); e.raw.played = false; });
else act(null, '✓', 'Gehört', () => { markPlayed(e); e.raw.played = true; e.raw.position_seconds = 0; });
} }
function closeNotes() { function closeNotes() {
if (!notesSheet) return false; if (!notesSheet) return false;
notesSheet.remove(); notesSheet.remove();
notesSheet = null; notesSheet = null;
epScreen = null;
return true; return true;
} }
@ -3505,8 +3755,8 @@
function dlDelete(e, running, quiet) { function dlDelete(e, running, quiet) {
N.podcastDeleteDownload(e.id); N.podcastDeleteDownload(e.id);
// "Löschen nimmt aus der Warteschlange" (account): the app takes it out too. // "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); const out = !running && deleteDequeues(e.id);
if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht'); if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht');
} }
// The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here. // The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here.
@ -3565,7 +3815,10 @@
return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung', return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung',
action: () => dlStart(e)}]; action: () => dlStart(e)}];
} }
if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}]; if (d.state === 'done') {
return [{icon: '✕', svg: 'trash', label: deleteDequeues(e.id) ? 'Datei löschen · auch aus der Warteschlange' : 'Datei löschen',
hint: 'nur die Datei auf diesem Gerät · ' + size(d.bytes), action: () => dlDelete(e)}];
}
return [ return [
d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst && d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst &&
{icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)}, {icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)},
@ -3614,11 +3867,11 @@
action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({ action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({
icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value, icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value,
action: () => dlSet({cleanup: c.value})})))}, action: () => dlSet({cleanup: c.value})})))},
done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, done.length > 0 && {icon: '✕', svg: 'trash', label: `Alle Dateien löschen (${size(bytes)})`,
hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen', hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [ action: () => openSheet(`Alle ${done.length} Dateien löschen?`, [
{icon: '✕', label: 'Löschen', hint: size(bytes), danger: true, {icon: '✕', svg: 'trash', label: 'Alle Dateien löschen', hint: size(bytes), danger: true,
action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }}, action: () => { N.podcastDeleteAllDownloads(); toast('Dateien gelöscht'); }},
{icon: '‹', label: 'Abbrechen', action: () => {}}, {icon: '‹', label: 'Abbrechen', action: () => {}},
])}, ])},
]; ];
@ -3916,6 +4169,7 @@
// The current episode changed: the queue view marks it. // The current episode changed: the queue view marks it.
if (before.episodeId !== player.episodeId || before.kind !== player.kind) render(); if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
else if (!!before.playing !== !!player.playing) playButtons(); else if (!!before.playing !== !!player.playing) playButtons();
if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts();
}; };
// The app loads this in <head>; the browser may load it after the page is parsed. // The app loads this in <head>; the browser may load it after the page is parsed.

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 339de8f 2026-10-02: the podcast parts of web-native/static/native.css /* Copied from diora-android ee1a910 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -269,10 +269,13 @@ html.nui-one-player #nui-mini { display: none !important; }
} }
.nui-pod-queued { color: var(--green); } .nui-pod-queued { color: var(--green); }
/* Narrow, and off the screen's edge: a swipe starting at the very edge (or sideways on the
handle, see dragSort) is the row's swipe; only a vertical drag on the handle sorts. */
.nui-pod-handle { .nui-pod-handle {
flex: none; flex: none;
width: var(--nui-tap); width: 28px;
height: var(--nui-tap); height: var(--nui-tap);
margin-right: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -472,6 +475,20 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
.nui-pod-notes table { display: block; overflow-x: auto; } .nui-pod-notes table { display: block; overflow-x: auto; }
.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; } .nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
.nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); } .nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); }
/* The episode screen (a tap on a row): head, the big play button, the actions, then notes. */
.nui-pod-episode .nui-pod-player-panel { max-height: 92%; }
.nui-pod-ephead { display: flex; align-items: flex-start; gap: 12px; }
.nui-pod-ephead .nui-pod-art-big { width: 88px; height: 88px; border-radius: 6px; flex: none; }
.nui-pod-ephead .nui-pod-player-title { text-align: left; font-size: 17px; }
.nui-pod-eplink { align-self: flex-start; border: none; background: none; padding: 4px 0; color: var(--nui-muted); font: inherit; font-size: 14px; text-align: left; cursor: pointer; text-decoration: underline; text-decoration-color: #555; }
.nui-pod-epplay { align-self: stretch; font-size: 16px; font-weight: 700; }
.nui-pod-epactions { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; }
.nui-pod-epact { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 60px; padding: 6px 4px; border: 1px solid var(--border); border-radius: 8px; background: none; color: inherit; font: inherit; font-size: 12.5px; line-height: 1.2; text-align: center; cursor: pointer; }
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
.nui-pod-epact-danger { color: var(--red, #e5484d); }
body.eink .nui-pod-epact { border-color: #000; color: #000; }
body.eink .nui-pod-epact-danger { color: #000; }
body.eink .nui-pod-chapter.current, body.eink .nui-pod-chapter.current,
body.eink .nui-pod-chapter.current .nui-pod-chapter-t, body.eink .nui-pod-chapter.current .nui-pod-chapter-t,
body.eink .nui-pod-notes a { color: #000; } body.eink .nui-pod-notes a { color: #000; }
@ -637,14 +654,23 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 18px; padding: 0 14px;
background: #161616; background: #161616;
color: var(--nui-muted); color: var(--nui-muted);
font-size: 14px; font-size: 14px;
white-space: nowrap; white-space: nowrap;
pointer-events: none; pointer-events: none;
} }
.nui-pod-swipebg.armed { background: #2c2c2c; color: var(--fg, #fff); font-weight: 700; } .nui-pod-swipebg.armed { background: #2c2c2c; color: var(--fg, #fff); }
/* Behind the row: the icon, under it the action in bold and what it means for this episode
(narrow, so it shows whole by the time the swipe is armed). */
.nui-pod-swipe-l, .nui-pod-swipe-r { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
.nui-pod-swipe-r { align-items: flex-end; }
.nui-pod-swipe-icon { font-size: 18px; line-height: 22px; }
.nui-pod-swipe-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; font-family: var(--nui-sans); letter-spacing: normal; }
.nui-pod-swipe-r .nui-pod-swipe-text { align-items: flex-end; text-align: right; }
.nui-pod-swipe-text b { font-weight: 700; font-size: 13px; }
.nui-pod-swipe-text small { font-size: 12px; font-weight: 400; opacity: 0.75; }
#nui-pod-undo { #nui-pod-undo {
position: fixed; position: fixed;
left: 50%; left: 50%;
@ -666,6 +692,13 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
transition: opacity 0.18s; transition: opacity 0.18s;
} }
#nui-pod-undo.nui-show { opacity: 1; pointer-events: auto; } #nui-pod-undo.nui-show { opacity: 1; pointer-events: auto; }
/* Line icons (native-podcast.js svgIcon): stroke in the text's colour. */
.nui-svg { display: inline-flex; align-items: center; justify-content: center; line-height: 0; }
.nui-svg svg { display: block; }
.nui-row-icon .nui-svg { width: 100%; }
.nui-pod-selactions .btn .nui-svg { margin-right: 6px; vertical-align: -3px; }
.nui-pod-undo-icon { display: inline-flex; margin-right: -6px; }
.nui-pod-undo-icon[hidden] { display: none; }
.nui-pod-undo-text { font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .nui-pod-undo-text { font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nui-pod-undo-btn { .nui-pod-undo-btn {
min-height: 40px; min-height: 40px;