Podcasts: "Folge löschen" (episodes/discard/, discarded in state/), Folgen-Seite, sw v69
- 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:
parent
461b42a5bf
commit
985e635f9a
9 changed files with 624 additions and 81 deletions
16
podcasts/migrations/0015_episodeprogress_discarded_at.py
Normal file
16
podcasts/migrations/0015_episodeprogress_discarded_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue