diff --git a/podcasts/migrations/0015_episodeprogress_discarded_at.py b/podcasts/migrations/0015_episodeprogress_discarded_at.py new file mode 100644 index 0000000..52812cf --- /dev/null +++ b/podcasts/migrations/0015_episodeprogress_discarded_at.py @@ -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), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index acf81a4..39eaa6a 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -201,6 +201,10 @@ class EpisodeProgress(models.Model): played = models.BooleanField(default=False) dismissed = 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) class Meta: diff --git a/podcasts/tests.py b/podcasts/tests.py index 9dae24c..3998d5b 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -2514,3 +2514,117 @@ class PlaybackSettingsTests(TestCase): self.client.logout() self.assertEqual(self.post({'skip_back': 10}).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) diff --git a/podcasts/urls.py b/podcasts/urls.py index d739850..348a522 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('episodes/', views.episode_list, name='podcast_episode_list'), path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'), + path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'), path('episodes//', views.episode_detail, name='podcast_episode_detail'), path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'), diff --git a/podcasts/views.py b/podcasts/views.py index 21825b3..3ef88d0 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -112,10 +112,18 @@ def _enqueue(user, episode_ids, where, current_hint=None): ) _write_queue_order(user, queued[:at] + new + queued[at:]) _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) 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): """Sort key for one queue row ({pub_date, duration, position, feed_title}) by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order @@ -1570,8 +1578,9 @@ def refresh_all_feeds(request): def podcast_state(request): """Everything a client shows first, in one response: feeds, queue, the first page of the inbox (inbox_limit of inbox_total new episodes), the - settings and how many AntennaPod downloads wait to be offered - (import-downloads/).""" + settings, how many AntennaPod downloads wait to be offered + (import-downloads/) and the episodes discarded lately ("Folge löschen": + a device deletes its older download of them).""" if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) try: @@ -1588,6 +1597,7 @@ def podcast_state(request): 'inbox_limit': limit, 'settings': _settings_data(s), 'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)}, + 'discarded': _discarded_data(request.user), 'server_time': timezone.now().isoformat(), }) @@ -1680,8 +1690,7 @@ def queue_remove(request): except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'invalid JSON'}, status=400) - episode_id = body.get('episode_id') - PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete() + _dequeue(request.user, [body.get('episode_id')]) return JsonResponse({'ok': True}) @@ -1940,6 +1949,62 @@ def dismiss_episodes(request): 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 # --------------------------------------------------------------------------- diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index c505aff..b2def5f 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -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) { edit(s => { const settings = s.settings || (s.settings = {}); @@ -742,12 +759,43 @@ 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) { if (curId() === id) { resume(); return; } const e = entry(id); 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. function skipOutro(id) { if (outroDone === id) return; @@ -1512,6 +1560,7 @@ }, podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)), podcastDismiss: sent(json => dismiss(ids(json))), + podcastDiscard: sent(json => discard(ids(json))), podcastSettings: sent(json => setSettings(parse(json, {}) || {})), podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)), podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})), @@ -1529,6 +1578,13 @@ playEpisode(id); 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(), podcastToggle: () => (wantPlay ? pause() : resume()), podcastSeek: ms => seek(Number(ms)), diff --git a/static/js/sw.js b/static/js/sw.js index b784aa0..00dd508 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v68'; +const CACHE = 'diora-v69'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 66495c6..fc79308 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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 // and copy again. The browser host is static/js/podcasts-web.js. @@ -135,8 +135,33 @@ 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: '', + listx: '', + }; + function svgIcon(name, size) { + const span = el('span', 'nui-svg'); + const px = size || 20; + span.innerHTML = ``; + 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) { - 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) { @@ -281,18 +306,58 @@ const selectItem = (e, opts) => opts && opts.select && {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) { const playing = currentId() === e.id; openSheet(e.title, [ {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), !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 unten', action: () => moveTo(e, 'bottom')}, {icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(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), ]); } @@ -301,17 +366,19 @@ const queued = inQueue(e.id); openSheet(e.title, [ {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), !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: '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 ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)}, ...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]))}, + discardItem(e, opts), selectItem(e, opts), ]); } @@ -444,8 +511,8 @@ // 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); + return accountSwitch('delete_dequeue', '„Datei löschen“ nimmt aus der Warteschlange', + '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". @@ -681,6 +748,7 @@ const acts = el('div', 'nui-pod-selactions'); for (const a of actions.filter(Boolean)) { 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.disabled = !selected.size; b.addEventListener('click', () => { @@ -715,12 +783,13 @@ {icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)}, ])}, // 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)); if (gone.length) N.podcastDismiss(JSON.stringify(gone)); toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“'); stopSelecting(); }}, + discardAction(), ].filter(Boolean)); } @@ -913,6 +982,7 @@ } function render() { + episodeScreenParts(); if (!root) return; const scroll = scrollTop(); // Search results arriving while typing: the field keeps its text and the cursor. @@ -1057,9 +1127,10 @@ row.addEventListener('click', () => toggleSelected(e.id)); 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.addEventListener('click', () => play(e)); + row.addEventListener('click', () => openEpisode(e, how)); // Swipes: touch only, not while the queue is locked. const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : ''; rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes); @@ -1128,8 +1199,22 @@ bg.dataset.dir = d; bg.classList.toggle('armed', armed()); 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}`); - bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l').textContent = ''; + const other = bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l'); + 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 => { row.classList.remove('nui-pod-swiping'); @@ -1144,14 +1229,17 @@ } }; 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'}; mode = ''; dx = vx = 0; last = {x: ev.clientX, t: ev.timeStamp}; swallow = false; stopTimer(); - if (!ev.target.closest('button')) { + if (!onHandle && !ev.target.closest('button')) { timer = setTimeout(() => { timer = 0; if (mode || !start) return; @@ -1230,9 +1318,10 @@ 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 = { - queue: {label: 'Warteschlange', left: 'delete', right: 'dequeue'}, + queue: {label: 'Warteschlange', left: N.podcastDiscard ? 'discard' : 'delete', right: 'dequeue'}, inbox: {label: 'Neu', left: 'dismiss', right: 'queue'}, episodes: {label: 'Folgen', left: 'download', right: 'favorite'}, feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'}, @@ -1241,13 +1330,19 @@ const SWIPE_ACTIONS = [ {id: 'none', label: 'Nichts', 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: 'delete', label: 'Download löschen', icon: '✕', dl: true}, - {id: 'played', label: 'Gehört / ungehört', icon: '✓'}, + {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', hint: 'als gehört markieren', 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() { let all = {}; @@ -1255,17 +1350,46 @@ 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) { const l = SWIPE_LISTS[list]; if (!l) return 'none'; const own = (swipePrefs()[list] || {})[dir]; const id = own || l[dir]; 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) { - return {played: 'Gehört', favorite: 'Favorit', dequeue: 'Entfernen', delete: 'Löschen', dismiss: 'Aus „Neu“'}[a.id] || a.label; + // What the swipe says behind the moving row: the action by its full name (bold) and what + // 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() { @@ -1280,9 +1404,9 @@ function swipeListSheet(list) { const l = SWIPE_LISTS[list]; 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, - hint: a.id === l[dir] ? 'Standard' : null, + hint: [a.hint, a.id === l[dir] ? 'Standard' : null].filter(Boolean).join(' · ') || null, action: () => { const all = swipePrefs(); 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). - function undoBar(text) { + function undoBar(text, svg) { let bar = $id('nui-pod-undo'); if (!text) { if (bar) bar.classList.remove('nui-show'); return; } if (!bar) { bar = el('div'); 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'); b.type = 'button'; b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); }); @@ -1348,6 +1472,10 @@ const t = $id('nui-radio-toast'); if (t) t.classList.remove('nui-show'); 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'); } @@ -1355,7 +1483,7 @@ commitSwipe(); pendingSwipe = Object.assign({id: e.id}, change); pendingSwipe.timer = setTimeout(commitSwipe, 4000); - undoBar(text); + undoBar(text, change.svg); if (change.hide) render(); } @@ -1377,7 +1505,8 @@ if (act === 'dequeue') { if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; } 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; } if (act === 'download') { @@ -1394,9 +1523,10 @@ if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; } const running = !has('done') && !has('failed'); // "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); - 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; } if (act === 'played') { @@ -1424,9 +1554,10 @@ // „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; } 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; } + if (act === 'discard' && N.podcastDiscard) return discardOne(e, list, filters); 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 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: 'Entfernen', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, + {label: 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, + discardAction(), ])); } else { const head = el('div', 'nui-pod-head'); @@ -1497,8 +1629,9 @@ {icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')}, ])}, {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))}, + discardAction(), ])); } else { const head = el('div', 'nui-pod-head'); @@ -2571,19 +2704,37 @@ // ---- 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) { handle.addEventListener('pointerdown', ev => { - ev.preventDefault(); - ev.stopPropagation(); - handle.setPointerCapture(ev.pointerId); - const rows = [...list.children]; - const from = rows.indexOf(row); - const rects = rows.map(r => r.getBoundingClientRect()); + if (ev.pointerType === 'mouse' && ev.button !== 0) return; + if (ev.pointerType === 'mouse') ev.preventDefault(); // no text selection while dragging + const id = ev.pointerId; + const startX = ev.clientX; const startY = ev.clientY; - let to = from; - row.classList.add('nui-pod-dragging'); + 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'); + try { handle.setPointerCapture(id); } catch (e) {} + }; const move = e => { + if (e.pointerId !== id) return; 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)`; const mid = rects[from].top + rects[from].height / 2 + dy; to = rects.findIndex(r => mid < r.top + r.height / 2); @@ -2596,10 +2747,16 @@ r.style.transform = shift ? `translateY(${shift}px)` : ''; }); }; - const end = () => { - handle.removeEventListener('pointermove', move); - handle.removeEventListener('pointerup', end); - handle.removeEventListener('pointercancel', end); + // Listened to on the window: before the drag starts the finger may leave the narrow handle. + const stop = () => { + window.removeEventListener('pointermove', move); + 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 = ''; }); row.classList.remove('nui-pod-dragging'); if (to !== from) { @@ -2608,9 +2765,9 @@ N.podcastMove(parseInt(row.dataset.id, 10), to); } }; - handle.addEventListener('pointermove', move); - handle.addEventListener('pointerup', end); - handle.addEventListener('pointercancel', end); + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', end); }); } @@ -2806,8 +2963,9 @@ } } - // Chapters and shownotes into a box; with `live`, taps jump in the playing episode. - async function showDetails(box, id, live) { + // Chapters and shownotes into a box; with `live`, taps jump in the playing episode, + // else with `jump(seconds)` (the episode screen) they start it there. + async function showDetails(box, id, live, jump) { box.textContent = ''; box.dataset.episode = String(id); let d = storedDetails(id); @@ -2841,6 +2999,7 @@ 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}`))); 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); }); body.append(ol); @@ -2851,13 +3010,14 @@ const notes = el('div', 'nui-pod-notes'); notes.innerHTML = d.description || '

Keine Shownotes.

'; notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank')); - if (live) { + if (live || jump) { linkStamps(notes); notes.addEventListener('click', ev => { const a = ev.target.closest('a.nui-pod-stamp'); if (!a) return; 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); @@ -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 epScreen = null; // {e, ctx, actions, playBtn} while the episode screen is open - function openNotes(e) { + function openEpisode(e, ctx) { closeNotes(); + ctx = ctx || {}; notesSheet = el('div'); 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 top = el('div', 'nui-pod-player-top'); 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 play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen'); - play1.type = 'button'; - play1.addEventListener('click', () => { closeNotes(); play(e); }); - panel.append(play1); + const head = el('div', 'nui-pod-ephead'); + head.append(art(e.art, 'big', e.feedArt, e.feed)); + const info = el('div', 'nui-pod-main'); + info.append(el('div', 'nui-pod-player-title', e.title)); + 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'); - panel.append(box); + panel.append(top, head, playBtn, actions, box); notesSheet.append(panel); notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); 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() { if (!notesSheet) return false; notesSheet.remove(); notesSheet = null; + epScreen = null; return true; } @@ -3505,8 +3755,8 @@ function dlDelete(e, running, quiet) { N.podcastDeleteDownload(e.id); // "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); - if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht'); + const out = !running && deleteDequeues(e.id); + 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. @@ -3565,7 +3815,10 @@ return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung', 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 [ d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst && {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 => ({ icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.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', - action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [ - {icon: '✕', label: 'Löschen', hint: size(bytes), danger: true, - action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }}, + action: () => openSheet(`Alle ${done.length} Dateien löschen?`, [ + {icon: '✕', svg: 'trash', label: 'Alle Dateien löschen', hint: size(bytes), danger: true, + action: () => { N.podcastDeleteAllDownloads(); toast('Dateien gelöscht'); }}, {icon: '‹', label: 'Abbrechen', action: () => {}}, ])}, ]; @@ -3916,6 +4169,7 @@ // The current episode changed: the queue view marks it. if (before.episodeId !== player.episodeId || before.kind !== player.kind) render(); else if (!!before.playing !== !!player.playing) playButtons(); + if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts(); }; // The app loads this in ; the browser may load it after the page is parsed. diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 0c103dc..63b901d 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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); } +/* 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 { flex: none; - width: var(--nui-tap); + width: 28px; height: var(--nui-tap); + margin-right: 10px; display: flex; align-items: 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-sheet .nui-pod-player-panel { max-height: 88%; } .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 .nui-pod-chapter-t, 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; align-items: center; justify-content: space-between; - padding: 0 18px; + padding: 0 14px; background: #161616; color: var(--nui-muted); font-size: 14px; white-space: nowrap; 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 { position: fixed; left: 50%; @@ -666,6 +692,13 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; } transition: opacity 0.18s; } #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-btn { min-height: 40px;