From 5be1b3fbaf689b5953a6826605eccf4b3e41afdb Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 16 Oct 2026 13:05:00 +0200 Subject: [PATCH] =?UTF-8?q?H=C3=B6rb=C3=BCcher:=20Podcasts=20als=20H=C3=B6?= =?UTF-8?q?rbuch=20markieren=20und=20in=20=E2=80=9EB=C3=BCcher=E2=80=9C=20?= =?UTF-8?q?h=C3=B6ren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind). Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen- Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books (static/js/audiobooks.js, eine Datei für Web, Desktop und Android): „Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge, Lesezeichen mit Notiz, Schlummer bis Kapitelende. Server: /podcasts/audiobooks/ (+/), /podcasts/bookmarks/…, Migration 0019. Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag: docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500. Co-Authored-By: Claude Opus 5.5 --- docs/audiobooks.md | 112 +++++ podcasts/audiobooks.py | 212 +++++++++ podcasts/migrations/0019_audiobooks.py | 35 ++ podcasts/models.py | 21 + podcasts/test_audiobooks.py | 135 ++++++ podcasts/tests.py | 4 +- podcasts/urls.py | 7 +- podcasts/views.py | 20 +- static/js/audiobooks.js | 600 +++++++++++++++++++++++++ static/js/podcasts-web.js | 44 +- static/js/sw.js | 3 +- static/podcasts/native-podcast.js | 182 ++++---- static/podcasts/podcast-ui.css | 190 +------- templates/radio/player.html | 1 + 14 files changed, 1283 insertions(+), 283 deletions(-) create mode 100644 docs/audiobooks.md create mode 100644 podcasts/audiobooks.py create mode 100644 podcasts/migrations/0019_audiobooks.py create mode 100644 podcasts/test_audiobooks.py create mode 100644 static/js/audiobooks.js diff --git a/docs/audiobooks.md b/docs/audiobooks.md new file mode 100644 index 0000000..00d57af --- /dev/null +++ b/docs/audiobooks.md @@ -0,0 +1,112 @@ +# Audiobooks (Hörbücher & Hörspiele) + +A podcast subscription can be marked as an audiobook. It then leaves the +podcast world (no "Neu", no queue automation, not in the podcast lists) and +shows up in the library ("Bücher") next to the e-books, where it is listened to +like a book: its episodes are chapters in order, "Weiterhören" goes on where +the last chapter stopped, the whole book has one progress, places can be kept +as bookmarks with a note, and the sleep timer can stop at the end of a chapter. + +## Server (diora-web) + +### The mark + +`PodcastFeed.kind`: `"podcast"` (default) or `"audiobook"`. A feed setting like +the others: in `state.feeds[]` (`/podcasts/state/`), set with +`POST /podcasts/feeds//settings/ {"kind": "audiobook"}`. + +Marked as an audiobook: +- its episodes leave "Neu" (dismissed, not played); new ones never get there + and are never queued, whatever `new_episodes` says; +- it is left out of "Folgen" (`/podcasts/episodes/` without `feed_id`); +- nothing else changes: places, played marks, downloads, settings stay. + Episodes of it already in the queue stay there until played or removed. + +### Chapters and progress + +Chapter order: `episode_number` (if set), then `pub_date`, then the title in +natural order ("Kapitel 2" before "Kapitel 10"), then id. + +`GET /podcasts/audiobooks/` → `{"books": [book, …]}`, the last listened first: + +``` +book = { + "feed_id", "title", "author", "artwork_url", + "chapters": 24, "done": 6, // played chapters + "total_seconds": 32400, "heard_seconds": 11400, + "next": {"episode_id", "index", "position_seconds"} | null, // null: finished + "last_played_at": ISO | null +} +``` + +`heard_seconds` = played chapters' durations + the places in the others. +Durations the feed doesn't have count as 0 until a player reports them +(`progress/save` with `duration_seconds`, see youtube.md). + +`GET /podcasts/audiobooks//` → `{"book": book, "chapters": [ch, …], +"bookmarks": [bm, …]}` with + +``` +ch = { // the queue entry's shape, so it can be handed to the player as it is + "episode__id", "episode__title", "episode__audio_url", "episode__duration_seconds", + "episode__artwork_url", "episode__feed__id", "episode__feed__title", + "episode__feed__artwork_url", "episode__pub_date", "position_seconds", "played", + "index" +} +bm = {"id", "episode_id", "position_ms", "note", "created_at"} // in chapter order, then place +``` + +### Bookmarks + +- `POST /podcasts/bookmarks/ {"episode_id", "position_ms", "note"}` → `{"ok", "bookmark": bm}` +- `POST /podcasts/bookmarks// {"note"}` → `{"ok", "bookmark": bm}` +- `POST /podcasts/bookmarks//delete/` → `{"ok"}` + +Any episode can have bookmarks; the library shows those of audiobooks. + +## The player (both hosts: podcasts-web.js and Android) + +One new host call, next to `podcastPlayEpisode`: + +`podcastPlayBook(json)` with `json = {"feed_id", "chapters": [ch, …], "index", "position_ms"}` +(`position_ms` optional: else the chapter's own place) + +- plays `chapters[index]` **without putting anything in the queue**; +- the book is what plays next: when a chapter ends it counts as played and the + next chapter follows from its own place; after the last one playing stops; + "next" (⏭, media keys) goes to the next chapter without marking the one left + as played; +- playing anything else (a queue episode, the radio's own handover aside) ends + the book context; the book context survives a restart of the page/app; +- the sleep timer needs nothing new: "end of chapter" is `podcastSleep(-1)` + (end of the episode) or, for an episode with chapters of its own, the minutes + until that chapter's end (`podcastSleep(ms / 60000)`, fractions are fine). + +`playerState()` (unchanged) tells the library which chapter plays where. + +## The library (diora-web `static/js/audiobooks.js`, everywhere) + +One file for the web page, the desktop app and the Android app (like +feeds.js; full flavour only, it needs the podcast host). It draws into +`#tab-books`, above `#book-list`, in its own container `#audiobook-shelf`: + +- "Weiterhören": the audiobook listened to last (unless finished); +- "Hörbücher": a card per audiobook — picture, title, author, + "3 h 10 von 9 h · Kapitel 7/24" and a progress bar; +- "+ Hörbuch": pick one of the podcast subscriptions to mark as an audiobook; +- the book view: picture, progress, ▶ Weiterhören / Von vorn, the chapter list + (tap: play it from its place; played ones ✓), bookmarks (tap: play from there; + note editable; ✕), "+ Lesezeichen" (at the place where this book plays now), + "Schlummer: Kapitelende", and ⋮ → "Wieder als Podcast", "Alles als ungehört". + +It talks to the server with fetch and to the player through the host +(`window.DioraNative` on Android, `window.DioraPodcastHost` elsewhere): +`podcastState`, `podcastFeedSettings`, `podcastPlayBook`, `playerState`, +`podcastSleep`, `podcastMarkPlayedAll`. + +## The podcast UI (native-podcast.js, diora-android) + +- feed settings sheet: "Als Hörbuch" (sets `kind`); +- audiobook feeds left out of Abos, Neu, Folgen, search results of the + subscriptions; a hint "In Bücher → Hörbücher" where one was moved; +- the player shows a book chapter as "Kapitel 7/24 · " when it plays one. diff --git a/podcasts/audiobooks.py b/podcasts/audiobooks.py new file mode 100644 index 0000000..707ccf9 --- /dev/null +++ b/podcasts/audiobooks.py @@ -0,0 +1,212 @@ +"""Audiobooks: podcast subscriptions marked as one (PodcastFeed.kind), listened to like +a book in the library — chapters in order, one progress, bookmarks (docs/audiobooks.md).""" +import json +import re +from datetime import datetime, timezone as dt_timezone + +from django.db.models import Max, Q +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import EpisodeBookmark, EpisodeProgress, PodcastEpisode, PodcastFeed + +MAX_NOTE = 1000 +_FAR = datetime.max.replace(tzinfo=dt_timezone.utc) +_DIGITS = re.compile(r'(\d+)') + +CHAPTER_FIELDS = ('id', 'title', 'audio_url', 'duration_seconds', 'artwork_url', 'pub_date', 'episode_number') + + +def _natural(title): + """'Kapitel 2' before 'Kapitel 10'.""" + return [int(part) if part.isdigit() else part.casefold() for part in _DIGITS.split(title or '')] + + +def chapter_key(ep): + number = ep['episode_number'] + return (number is None, number or 0, ep['pub_date'] or _FAR, _natural(ep['title']), ep['id']) + + +def _chapters(user, feed): + """The feed's episodes in chapter order, with the user's place and played mark.""" + eps = sorted(PodcastEpisode.objects.filter(feed=feed).values(*CHAPTER_FIELDS), key=chapter_key) + progress = { + p['episode_id']: p for p in EpisodeProgress.objects.filter(user=user, episode__feed=feed) + .values('episode_id', 'position_seconds', 'played', 'updated_at') + } + out = [] + for i, ep in enumerate(eps): + p = progress.get(ep['id']) or {} + out.append({ + 'episode__id': ep['id'], + 'episode__title': ep['title'], + 'episode__audio_url': ep['audio_url'], + 'episode__duration_seconds': ep['duration_seconds'], + 'episode__artwork_url': ep['artwork_url'], + 'episode__feed__id': feed.id, + 'episode__feed__title': feed.title, + 'episode__feed__artwork_url': feed.artwork_url, + 'episode__pub_date': ep['pub_date'].isoformat() if ep['pub_date'] else None, + 'position_seconds': p.get('position_seconds') or 0, + 'played': bool(p.get('played')), + 'index': i, + # When it was last listened to (a place or played), for where the book goes on. + '_heard_at': p.get('updated_at') if (p.get('played') or p.get('position_seconds')) else None, + }) + return out + + +def _next(chapters): + """Where the book goes on: in the chapter listened to last if it isn't finished, else + in the first unfinished one after it (or before it, when the end was reached). Never + listened: the first unfinished one. None: all finished.""" + open_ = [ch for ch in chapters if not ch['played']] + if not open_: + return None + heard = [ch for ch in chapters if ch['_heard_at']] + if heard: + last = max(heard, key=lambda ch: ch['_heard_at']) + after = [ch for ch in open_ if ch['index'] >= last['index']] + ch = after[0] if after else open_[0] + else: + ch = open_[0] + return {'episode_id': ch['episode__id'], 'index': ch['index'], 'position_seconds': ch['position_seconds']} + + +def _book(feed, chapters, last_played_at): + total = heard = done = 0 + for ch in chapters: + dur = max(ch['episode__duration_seconds'] or 0, 0) + total += dur + if ch['played']: + done += 1 + heard += dur + elif dur: + heard += min(ch['position_seconds'], dur) + nxt = _next(chapters) + return { + 'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url, + 'chapters': len(chapters), 'done': done, 'total_seconds': total, 'heard_seconds': heard, + 'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None, + } + + +def _bookmark_json(bm): + return {'id': bm.id, 'episode_id': bm.episode_id, 'position_ms': bm.position_ms, 'note': bm.note, + 'created_at': bm.created_at.isoformat()} + + +def _last_played(user, feed_ids): + """When each book was last listened to: a place or a played mark, not the rows that + only took its chapters out of "Neu" when it was marked.""" + return dict( + EpisodeProgress.objects.filter(user=user, episode__feed_id__in=feed_ids) + .filter(Q(position_seconds__gt=0) | Q(played=True)) + .values('episode__feed_id').annotate(at=Max('updated_at')).values_list('episode__feed_id', 'at') + ) + + +@csrf_exempt +@require_http_methods(['GET']) +def audiobook_list(request): + """The user's audiobooks with their progress, the last listened first.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + feeds = list(PodcastFeed.objects.filter(user=request.user, kind=PodcastFeed.AUDIOBOOK)) + last = _last_played(request.user, [f.id for f in feeds]) + books = [_book(f, _chapters(request.user, f), last.get(f.id)) for f in feeds] + books.sort(key=lambda b: b['title'].casefold()) + books.sort(key=lambda b: b['last_played_at'] or '', reverse=True) # never listened: last + return JsonResponse({'books': books}) + + +@csrf_exempt +@require_http_methods(['GET']) +def audiobook_detail(request, feed_id): + """One audiobook: its progress, its chapters (shaped like queue entries, for the + player) and its bookmarks in chapter order.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + feed = PodcastFeed.objects.filter(pk=feed_id, user=request.user, kind=PodcastFeed.AUDIOBOOK).first() + if feed is None: + return JsonResponse({'error': 'not found'}, status=404) + chapters = _chapters(request.user, feed) + order = {ch['episode__id']: ch['index'] for ch in chapters} + marks = sorted(EpisodeBookmark.objects.filter(user=request.user, episode__feed=feed), + key=lambda bm: (order.get(bm.episode_id, 0), bm.position_ms, bm.id)) + book = _book(feed, chapters, _last_played(request.user, [feed.id]).get(feed.id)) + for ch in chapters: + del ch['_heard_at'] + return JsonResponse({ + 'book': book, + 'chapters': chapters, + 'bookmarks': [_bookmark_json(bm) for bm in marks], + }) + + +def _body(request): + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return None + return body if isinstance(body, dict) else None + + +def _note(value): + if value is None: + return '' + if not isinstance(value, str): + raise ValueError + return value.strip()[:MAX_NOTE] + + +@csrf_exempt +@require_http_methods(['POST']) +def bookmark_add(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + body = _body(request) + if body is None: + return JsonResponse({'error': 'invalid JSON'}, status=400) + episode = PodcastEpisode.objects.filter(pk=body.get('episode_id'), feed__user=request.user).first() \ + if isinstance(body.get('episode_id'), int) else None + if episode is None: + return JsonResponse({'error': 'not found'}, status=404) + pos = body.get('position_ms') + if not isinstance(pos, (int, float)) or isinstance(pos, bool) or not 0 <= pos < 2 ** 31: + return JsonResponse({'error': 'invalid position_ms'}, status=400) + try: + note = _note(body.get('note')) + except ValueError: + return JsonResponse({'error': 'invalid note'}, status=400) + bm = EpisodeBookmark.objects.create(user=request.user, episode=episode, position_ms=int(pos), note=note) + return JsonResponse({'ok': True, 'bookmark': _bookmark_json(bm)}) + + +@csrf_exempt +@require_http_methods(['POST']) +def bookmark_update(request, pk): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + bm = EpisodeBookmark.objects.filter(pk=pk, user=request.user).first() + body = _body(request) + if bm is None: + return JsonResponse({'error': 'not found'}, status=404) + if body is None: + return JsonResponse({'error': 'invalid JSON'}, status=400) + try: + bm.note = _note(body.get('note')) + except ValueError: + return JsonResponse({'error': 'invalid note'}, status=400) + bm.save(update_fields=['note']) + return JsonResponse({'ok': True, 'bookmark': _bookmark_json(bm)}) + + +@csrf_exempt +@require_http_methods(['POST']) +def bookmark_delete(request, pk): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + EpisodeBookmark.objects.filter(pk=pk, user=request.user).delete() + return JsonResponse({'ok': True}) diff --git a/podcasts/migrations/0019_audiobooks.py b/podcasts/migrations/0019_audiobooks.py new file mode 100644 index 0000000..3323ded --- /dev/null +++ b/podcasts/migrations/0019_audiobooks.py @@ -0,0 +1,35 @@ +# Generated by Django 6.1 on 2026-10-16 10:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0018_episode_silences'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='kind', + field=models.CharField(choices=[('podcast', 'Podcast'), ('audiobook', 'Hörbuch')], default='podcast', max_length=10), + ), + migrations.CreateModel( + name='EpisodeBookmark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position_ms', models.PositiveIntegerField()), + ('note', models.CharField(blank=True, max_length=1000)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='podcasts.podcastepisode')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_bookmarks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['position_ms', 'id'], + }, + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index a3c95af..28dc2fd 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -19,6 +19,11 @@ class PodcastFeed(models.Model): ] # Volume factors: below 1 quieter, above 1 louder (the apps boost those). VOLUMES = (0.2, 0.5, 1.0, 1.5, 2.0, 2.5) + # What it is: a podcast, or an audiobook (Hörbuch, Hörspiel), which lives in the + # library: its episodes are chapters in order, never news (docs/audiobooks.md). + PODCAST = 'podcast' + AUDIOBOOK = 'audiobook' + KINDS = [(PODCAST, 'Podcast'), (AUDIOBOOK, 'Hörbuch')] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds') rss_url = models.URLField(max_length=1000) @@ -69,6 +74,7 @@ class PodcastFeed(models.Model): episode_filter = models.JSONField(default=list, blank=True) # Skip silence in this podcast's episodes: null = as the device is set. skip_silence = models.BooleanField(null=True, blank=True) + kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST) # The vocabulary of episode_sort and episode_filter: the podcast page's # sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS). @@ -222,6 +228,21 @@ class EpisodeProgress(models.Model): return f'{self.user} - {self.episode}' +class EpisodeBookmark(models.Model): + """A place in an episode kept with a note (an audiobook's bookmarks).""" + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_bookmarks') + episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='bookmarks') + position_ms = models.PositiveIntegerField() + note = models.CharField(max_length=1000, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['position_ms', 'id'] + + def __str__(self): + return f'{self.user} - {self.episode} @ {self.position_ms} ms' + + class PodcastQueue(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_queue') episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='queued_by') diff --git a/podcasts/test_audiobooks.py b/podcasts/test_audiobooks.py new file mode 100644 index 0000000..da9014d --- /dev/null +++ b/podcasts/test_audiobooks.py @@ -0,0 +1,135 @@ +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import feedparser +from django.contrib.auth.models import User +from django.test import TestCase + +from .models import EpisodeBookmark, EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue +from .views import FeedFetch, _refresh_feed + +T0 = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +class AudiobookTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/book', title='Das Buch', + author='A. Autor', last_refreshed_at=T0) + # Out of order on purpose: the same date, chapter numbers only in the titles. + self.eps = {} + for title, dur in [('Kapitel 10', 600), ('Kapitel 2', 300), ('Kapitel 1', 100), ('Vorwort', 0)]: + self.eps[title] = PodcastEpisode.objects.create( + feed=self.feed, guid=title, title=title, audio_url=f'https://example.org/{title}.mp3', + duration_seconds=dur, pub_date=T0 - (timedelta(days=1) if title == 'Vorwort' else timedelta(0))) + + def post(self, url, body): + return self.client.post(url, json.dumps(body), content_type='application/json') + + def mark(self): + r = self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'kind': 'audiobook'}) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()['kind'], 'audiobook') + + def test_marking_takes_its_episodes_out_of_neu_without_playing_them(self): + self.mark() + self.assertIsNone(self.client.get('/podcasts/audiobooks/').json()['books'][0]['last_played_at']) + self.assertEqual(EpisodeProgress.objects.filter(episode__feed=self.feed, dismissed=True).count(), 4) + self.assertFalse(EpisodeProgress.objects.filter(played=True).exists()) + + def test_an_unknown_kind_is_refused(self): + r = self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'kind': 'radio'}) + self.assertEqual(r.status_code, 400) + + def test_chapters_in_order_and_one_progress_for_the_book(self): + self.mark() + EpisodeProgress.objects.filter(episode=self.eps['Vorwort']).update(played=True) + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 1']).update(played=True) + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(position_seconds=120) + d = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json() + self.assertEqual([c['episode__title'] for c in d['chapters']], ['Vorwort', 'Kapitel 1', 'Kapitel 2', 'Kapitel 10']) + self.assertEqual([c['index'] for c in d['chapters']], [0, 1, 2, 3]) + book = d['book'] + self.assertEqual((book['chapters'], book['done'], book['total_seconds'], book['heard_seconds']), (4, 2, 1000, 220)) + self.assertEqual(book['next'], {'episode_id': self.eps['Kapitel 2'].pk, 'index': 2, 'position_seconds': 120}) + listed = self.client.get('/podcasts/audiobooks/').json()['books'] + self.assertEqual([b['feed_id'] for b in listed], [self.feed.pk]) + + def test_it_goes_on_where_it_was_listened_to_last_not_at_a_skipped_chapter(self): + self.mark() + # Started at Kapitel 1 (the foreword skipped), finished it, now in Kapitel 2. + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 1']).update(played=True, position_seconds=100, updated_at=T0 + timedelta(hours=1)) + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(position_seconds=30, updated_at=T0 + timedelta(hours=2)) + nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next'] + self.assertEqual(nxt['episode_id'], self.eps['Kapitel 2'].pk) + # Kapitel 2 finished as well (last): on with Kapitel 10, not back to the foreword. + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(played=True, updated_at=T0 + timedelta(hours=3)) + nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next'] + self.assertEqual(nxt['episode_id'], self.eps['Kapitel 10'].pk) + # The end reached with the foreword still open: that one. + EpisodeProgress.objects.filter(episode=self.eps['Kapitel 10']).update(played=True, updated_at=T0 + timedelta(hours=4)) + nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next'] + self.assertEqual(nxt['episode_id'], self.eps['Vorwort'].pk) + + def test_a_podcast_is_no_audiobook(self): + self.assertEqual(self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').status_code, 404) + self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'], []) + + def test_a_finished_book_has_no_next(self): + self.mark() + EpisodeProgress.objects.filter(episode__feed=self.feed).update(played=True) + self.assertIsNone(self.client.get('/podcasts/audiobooks/').json()['books'][0]['next']) + + def test_audiobooks_stay_out_of_folgen(self): + self.mark() + self.assertEqual(self.client.get('/podcasts/episodes/').json()['episodes'], []) + d = self.client.get('/podcasts/episodes/', {'feed_id': self.feed.pk}).json() + self.assertEqual(len(d['episodes']), 4) # in its own list they are + + def test_new_chapters_never_go_to_neu_or_the_queue(self): + self.feed.new_episodes = PodcastFeed.NEW_QUEUE + self.feed.save() + self.mark() + rss = ('Das Buch' + 'neuKapitel 11Mon, 05 Jan 2026 10:00:00 GMT' + '') + self.feed.refresh_from_db() + with patch('podcasts.views._fetch_feed', lambda url, **kw: FeedFetch(200, feedparser.parse(rss))): + _refresh_feed(self.feed) + new = PodcastEpisode.objects.get(guid='neu') + self.assertFalse(PodcastQueue.objects.filter(episode=new).exists()) + self.assertTrue(EpisodeProgress.objects.get(episode=new).dismissed) + + def test_bookmarks(self): + self.mark() + ep = self.eps['Kapitel 2'] + r = self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 61500, 'note': ' Wichtig '}) + self.assertEqual(r.status_code, 200, r.content) + bm = r.json()['bookmark'] + self.assertEqual((bm['episode_id'], bm['position_ms'], bm['note']), (ep.pk, 61500, 'Wichtig')) + self.post('/podcasts/bookmarks/', {'episode_id': self.eps['Kapitel 1'].pk, 'position_ms': 5}) + marks = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['bookmarks'] + self.assertEqual([m['episode_id'] for m in marks], [self.eps['Kapitel 1'].pk, ep.pk]) # chapter order + r = self.post(f'/podcasts/bookmarks/{bm["id"]}/', {'note': 'Anders'}) + self.assertEqual(r.json()['bookmark']['note'], 'Anders') + self.post(f'/podcasts/bookmarks/{bm["id"]}/delete/', {}) + self.assertEqual(EpisodeBookmark.objects.count(), 1) + + def test_bookmarks_are_ones_own(self): + self.mark() + bob = User.objects.create_user(username='bob', password='pw12345678') + mine = EpisodeBookmark.objects.create(user=self.user, episode=self.eps['Kapitel 1'], position_ms=1) + self.client.force_login(bob) + self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': self.eps['Kapitel 1'].pk, 'position_ms': 1}).status_code, 404) + self.assertEqual(self.post(f'/podcasts/bookmarks/{mine.pk}/', {'note': 'x'}).status_code, 404) + self.post(f'/podcasts/bookmarks/{mine.pk}/delete/', {}) + self.assertTrue(EpisodeBookmark.objects.filter(pk=mine.pk).exists()) + self.assertEqual(self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').status_code, 404) + + def test_bad_bookmarks_are_refused(self): + ep = self.eps['Kapitel 1'] + self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': -1}).status_code, 400) + self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 'x'}).status_code, 400) + self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 1, 'note': 5}).status_code, 400) diff --git a/podcasts/tests.py b/podcasts/tests.py index 7a608dd..a576f99 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1073,7 +1073,7 @@ class OpmlExportTests(TestCase): FEED_DEFAULTS = { 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, - 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, + 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', } @@ -1100,7 +1100,7 @@ class FeedSettingsTests(TestCase): 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'auto_queue': False, 'auto_download': False, 'notify': False, - 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, + 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', }) self.feed.refresh_from_db() self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), diff --git a/podcasts/urls.py b/podcasts/urls.py index a6ebd46..ed6d08e 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from . import views +from . import audiobooks, views urlpatterns = [ path('search/', views.podcast_search, name='podcast_search'), @@ -14,6 +14,11 @@ urlpatterns = [ path('feeds//set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'), path('feeds//settings/', views.feed_settings, name='podcast_feed_settings'), path('feeds//move/', views.move_feed, name='podcast_move_feed'), + path('audiobooks/', audiobooks.audiobook_list, name='podcast_audiobooks'), + path('audiobooks//', audiobooks.audiobook_detail, name='podcast_audiobook'), + path('bookmarks/', audiobooks.bookmark_add, name='podcast_bookmark_add'), + path('bookmarks//', audiobooks.bookmark_update, name='podcast_bookmark_update'), + path('bookmarks//delete/', audiobooks.bookmark_delete, name='podcast_bookmark_delete'), 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'), diff --git a/podcasts/views.py b/podcasts/views.py index ce5e208..c67913f 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -692,14 +692,14 @@ def _apply_feed(feed_obj, fetched, full=False): # 'inbox' leaves them there. if new_episodes_list: unwanted = [e for e in new_episodes_list if not feed_obj.matches_filter(e.title, e.duration_seconds)] - if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE: - unwanted = new_episodes_list + if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE or feed_obj.kind == PodcastFeed.AUDIOBOOK: + unwanted = new_episodes_list # an audiobook's new chapters wait in the library if unwanted: gone = {e.id for e in unwanted} _leave_inbox(feed_obj.user, list(gone)) new_episodes_list = [e for e in new_episodes_list if e.id not in gone] - if feed_obj.new_episodes == PodcastFeed.NEW_QUEUE and new_episodes_list: + if feed_obj.new_episodes == PodcastFeed.NEW_QUEUE and new_episodes_list and feed_obj.kind != PodcastFeed.AUDIOBOOK: where = PodcastSettings.for_user(feed_obj.user).auto_queue_position # Newest first in front of what's there; oldest first when appended, # so the end of the queue plays in the order the episodes came out. @@ -777,7 +777,7 @@ def feed_list(request): FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', - 'notify', 'episode_sort', 'episode_filter', 'skip_silence', + 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', ) @@ -975,6 +975,12 @@ def _volume(value): return float(value) +def _kind(value): + if value not in {key for key, _ in PodcastFeed.KINDS}: + raise ValueError + return value + + def _new_episodes(value): if value not in {key for key, _ in PodcastFeed.NEW_EPISODE_ACTIONS}: raise ValueError @@ -1008,6 +1014,7 @@ FEED_SETTING_PARSERS = { 'notify': _boolean, 'episode_sort': _episode_sort, 'episode_filter': _episode_filter, + 'kind': _kind, 'skip_silence': _optional_boolean, } @@ -1099,10 +1106,13 @@ def feed_settings(request, pk): changes[name] = parse(body[name]) except (ValueError, TypeError, OverflowError): return JsonResponse({'error': f'invalid {name}', 'field': name}, status=400) + became_book = changes.get('kind') == PodcastFeed.AUDIOBOOK and feed.kind != PodcastFeed.AUDIOBOOK if changes: for name, value in changes.items(): setattr(feed, name, value) feed.save(update_fields=list(changes)) + if became_book: # an audiobook's chapters aren't news (docs/audiobooks.md) + _leave_inbox(request.user, list(feed.episodes.values_list('id', flat=True))) return JsonResponse(_feed_settings(feed)) @@ -1461,6 +1471,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li qs = qs.filter(feed_id=feed_id) if ids is not None: qs = qs.filter(id__in=ids) + if feed_id is None and ids is None: # audiobooks are listened to in the library + qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK) order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')] if q and notes: qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)).annotate( diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js new file mode 100644 index 0000000..7e9ddd8 --- /dev/null +++ b/static/js/audiobooks.js @@ -0,0 +1,600 @@ +/* + * Audiobooks in the library (docs/audiobooks.md): podcast subscriptions marked as a + * Hörbuch or Hörspiel are listened to like a book, next to the e-books in "Bücher" — + * "Weiterhören", one progress per book, the chapters in order, bookmarks with a note, + * the sleep timer to the end of the chapter. The server knows the books + * (/podcasts/audiobooks/), the podcast host plays them (podcastPlayBook: the chapters in + * order, outside the queue). Runs in the web page, the desktop app and the Android app + * (not in diora Reader, which has no podcasts). Chrome-61-safe: no ?. / ??. + */ +(function () { + 'use strict'; + const tab = document.getElementById('tab-books'); + const list = document.getElementById('book-list'); + if (!tab || !list) return; + if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return; + + // The podcast host: the app's own on Android, podcasts-web.js elsewhere. + function host() { + const n = window.DioraNative; + if (n && typeof n.podcastPlayBook === 'function') return n; + const h = window.DioraPodcastHost; + return h && typeof h.podcastPlayBook === 'function' ? h : null; + } + + const st = { + books: null, // /podcasts/audiobooks/ books + open: null, // feed id of the book shown + detail: null, // /podcasts/audiobooks// + picking: false, // choosing a podcast to make an audiobook + pickQuery: '', + now: null, // playerState() of the last poll + }; + + const shelf = document.createElement('div'); + shelf.id = 'audiobook-shelf'; + // Above the e-books, and above their "Gelesene anzeigen" switch (it is about them). + const filter = list.previousElementSibling; + const before = filter && filter.classList.contains('book-list-filter') ? filter : list; + list.parentNode.insertBefore(shelf, before); + + // ---- helpers ------------------------------------------------------------------------ + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + } + + function hours(seconds) { + const m = Math.round(Math.max(0, seconds) / 60); + if (m < 60) return m + ' min'; + const h = Math.floor(m / 60); + return h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); + } + + function clock(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(s / 3600); + const mm = String(Math.floor(s / 60) % 60); + const ss = String(s % 60).padStart(2, '0'); + return h ? h + ':' + mm.padStart(2, '0') + ':' + ss : mm + ':' + ss; + } + + function progressText(b) { + const parts = []; + if (b.total_seconds > 0) parts.push(hours(b.heard_seconds) + ' von ' + hours(b.total_seconds)); + if (!b.next) parts.push('fertig gehört'); + else parts.push('Kapitel ' + (b.next.index + 1) + '/' + b.chapters); + return parts.join(' · '); + } + + function pct(b) { + if (b.total_seconds > 0) return Math.min(100, Math.round(100 * b.heard_seconds / b.total_seconds)); + return b.chapters ? Math.round(100 * b.done / b.chapters) : 0; + } + + function cover(url, title, cls) { + const art = String(url || '').replace(/^http:/, 'https:'); + return art + ? '' + : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; + } + + function toast(text) { + let t = document.getElementById('ab-toast'); + if (!t) { + t = document.createElement('div'); + t.id = 'ab-toast'; + document.body.appendChild(t); + } + t.textContent = text; + t.classList.add('ab-show'); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => t.classList.remove('ab-show'), 2600); + } + + function cached(name, value) { + try { + if (value === undefined) return JSON.parse(localStorage.getItem('diora_ab_' + name) || 'null'); + localStorage.setItem('diora_ab_' + name, JSON.stringify(value)); + } catch (e) {} + return null; + } + + async function api(path, body) { + const opts = {credentials: 'same-origin', cache: 'no-store'}; + if (body !== undefined) { + opts.method = 'POST'; + opts.headers = {'Content-Type': 'application/json'}; + opts.body = JSON.stringify(body); + } + const r = await fetch(path, opts); + const d = await r.json().catch(() => null); + if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status)); + return d; + } + + function podcastState() { + const h = host(); + if (!h) return null; + try { return JSON.parse(h.podcastState()); } catch (e) { return null; } + } + + function playerState() { + const h = host(); + if (!h || typeof h.playerState !== 'function') return null; + try { return JSON.parse(h.playerState()); } catch (e) { return null; } + } + + // The chapter of the open book playing (or paused) now, else null. + function nowInBook() { + const p = st.now; + const d = st.detail; + if (!p || !d || p.kind !== 'podcast') return null; + for (let i = 0; i < d.chapters.length; i++) { + if (d.chapters[i].episode__id === p.episodeId) return {index: i, pos: p.pos || 0, dur: p.dur || 0, playing: !!p.playing}; + } + return null; + } + + // ---- loading ------------------------------------------------------------------------ + + async function loadList() { + if (!host()) { shelf.innerHTML = ''; return; } + if (!st.books) { st.books = cached('books'); renderShelf(); } + try { + st.books = (await api('/podcasts/audiobooks/')).books || []; + cached('books', st.books); + } catch (e) { + if (!st.books) st.books = []; + } + renderShelf(); + } + + async function loadDetail(feedId) { + if (!st.detail || st.detail.book.feed_id !== feedId) st.detail = cached('book_' + feedId); + renderView(); + try { + const d = await api('/podcasts/audiobooks/' + feedId + '/'); + if (st.open !== feedId) return d; + st.detail = d; + cached('book_' + feedId, d); + } catch (e) { + if (!st.detail) toast('Das Hörbuch braucht eine Verbindung.'); + } + renderView(); + return st.detail; + } + + // ---- the shelf in "Bücher" ------------------------------------------------------------ + + function renderShelf() { + const h = host(); + const books = st.books || []; + const state = h ? podcastState() : null; + const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : []; + if (!h || (!books.length && !pods.length)) { shelf.innerHTML = ''; return; } + let html = ''; + const go = books.filter(b => b.next && b.last_played_at)[0]; + if (go) { + html += '
' + + cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') + + '
Weiterhören
' + + '
' + esc(go.title) + '
' + + '
' + esc(progressText(go)) + '
' + + '
' + + '
'; + } + html += '

Hörbücher

' + + (pods.length ? '' : '') + '
'; + if (books.length) { + html += '
' + books.map(b => + '').join('') + '
'; + } else { + html += '

Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' + + 'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.

'; + } + if (st.picking) html += pickHtml(pods); + shelf.innerHTML = html; + const input = shelf.querySelector('.ab-pick-q'); + if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; } + } + + function pickHtml(pods) { + const q = st.pickQuery.trim().toLowerCase(); + const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0) + .sort((a, b) => a.title.localeCompare(b.title)).slice(0, 60); + return '
Welcher Podcast ist ein Hörbuch?' + + '
' + + '' + + '
' + found.map(f => + '').join('') + + (found.length ? '' : '

Kein Abo gefunden.

') + '
'; + } + + function setKind(feedId, kind) { + const h = host(); + if (!h) return; + h.podcastFeedSettings(feedId, JSON.stringify({kind})); + // The host sends it on its own; the books are asked again once the server has it. + setTimeout(loadList, 900); + setTimeout(loadList, 3500); + } + + // ---- the book -------------------------------------------------------------------------- + + let view = null; + + function openBook(feedId) { + st.open = feedId; + if (!view) { + view = document.createElement('div'); + view.id = 'ab-view'; + view.addEventListener('click', onViewClick); + document.body.appendChild(view); + } + view.style.display = ''; + document.documentElement.classList.add('ab-open'); + view.scrollTop = 0; + poll(); + loadDetail(feedId); + } + + function closeBook() { + st.open = null; + if (view) view.style.display = 'none'; + document.documentElement.classList.remove('ab-open'); + loadList(); + } + + function renderView() { + if (!view || st.open == null) return; + const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null; + const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0]; + const b = d ? d.book : fromList; + if (!b) { view.innerHTML = '

Wird geladen …

'; return; } + const now = nowInBook(); + const marks = d ? d.bookmarks : []; + const titleOf = id => { + const ch = d ? d.chapters.filter(c => c.episode__id === id)[0] : null; + return ch ? 'Kapitel ' + (ch.index + 1) : ''; + }; + view.innerHTML = + '
' + + '
' + + '' + + '
' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') + + '

' + esc(b.title) + '

' + + (b.author ? '
' + esc(b.author) + '
' : '') + + '
' + esc(progressText(b)) + '
' + + '
' + + '
' + + '' + + '' + + '
' + + (marks.length ? '

Lesezeichen

' + marks.map(m => + '
' + + '' + + '
').join('') + '
' : '') + + '

Kapitel

' + + (d ? '
' + d.chapters.map(c => chapterHtml(c, now)).join('') + '
' + : '

Wird geladen …

'); + } + + function goLabel(b, now) { + if (now && now.playing) return '❚❚ Pause'; + if (!b.next) return '↺ Von vorn'; + return '▶ ' + (b.done || b.heard_seconds > 0 ? 'Weiterhören' : 'Anfangen'); + } + + function chapterHtml(c, now) { + const playing = now && now.index === c.index; + const dur = c.episode__duration_seconds || 0; + let meta; + if (c.played) meta = '✓ gehört'; + else if (playing && now.dur) meta = clock(now.pos) + ' / ' + clock(now.dur); + else if (c.position_seconds > 0 && dur) meta = 'noch ' + hours(dur - c.position_seconds); + else meta = dur ? hours(dur) : ''; + return ''; + } + + // Play the open book's chapter `index` (from its own place, or from `ms`). + function play(index, ms) { + const h = host(); + const d = st.detail; + if (!h || !d || !d.chapters[index]) return; + const ch = d.chapters[index]; + const body = {feed_id: d.book.feed_id, chapters: d.chapters, index}; + if (ms != null) body.position_ms = ms; + else if (ch.played) body.position_ms = 0; // heard already: from its start + h.podcastPlayBook(JSON.stringify(body)); + setTimeout(poll, 400); + } + + async function goOn(feedId) { + if (st.open !== feedId || !st.detail || st.detail.book.feed_id !== feedId) { + st.open = feedId; + await loadDetail(feedId); + if (!view || view.style.display === 'none') st.open = null; + } + const d = st.detail; + if (!d || d.book.feed_id !== feedId) return; + const b = d.book; + play(b.next ? b.next.index : 0, b.next ? undefined : 0); + } + + function togglePlay() { + const now = nowInBook(); + const h = host(); + if (now && now.playing && h) { + if (typeof h.podcastToggle === 'function') h.podcastToggle(); + else if (typeof h.podcastPause === 'function') h.podcastPause(); + setTimeout(poll, 300); + return; + } + if (now && h) { play(now.index, now.pos); return; } + goOn(st.open); + } + + // The sleep timer to the end of the chapter: the episode's own chapter mark if it has + // some (a long file), else the end of the episode. + async function sleepAtChapterEnd() { + const h = host(); + const now = nowInBook(); + if (!h || !now) return; + const id = st.detail.chapters[now.index].episode__id; + let marks = []; + try { marks = (await api('/podcasts/episodes/' + id + '/')).chapters || []; } catch (e) {} + const pos = (playerState() || {}).pos || now.pos; + const next = marks.map(m => m.start * 1000).filter(s => s > pos + 1000).sort((a, b) => a - b)[0]; + if (next) { + const speed = (st.now && st.now.speed) || 1; + h.podcastSleep((next - pos) / speed / 60000); + toast('Schlummer: in ' + clock((next - pos) / speed) + ', am Kapitelende'); + } else { + h.podcastSleep(-1); + toast('Schlummer: am Ende dieses Kapitels'); + } + } + + async function addBookmark() { + const now = nowInBook(); + if (!now) return; + const pos = (playerState() || {}).pos || now.pos; + const ch = st.detail.chapters[now.index]; + const note = typeof customPrompt === 'function' + ? await customPrompt('Lesezeichen bei Kapitel ' + (now.index + 1) + ', ' + clock(pos) + ' – Notiz (optional):', '') + : window.prompt('Notiz (optional):', ''); + if (note === null || note === undefined) return; + try { + await api('/podcasts/bookmarks/', {episode_id: ch.episode__id, position_ms: Math.round(pos), note: String(note)}); + toast('Lesezeichen gesetzt'); + } catch (e) { + toast('Lesezeichen brauchen eine Verbindung.'); + } + loadDetail(st.open); + } + + async function editBookmark(id) { + const m = st.detail.bookmarks.filter(x => x.id === id)[0]; + if (!m) return; + const note = typeof customPrompt === 'function' ? await customPrompt('Notiz:', m.note) : window.prompt('Notiz:', m.note); + if (note === null || note === undefined) return; + try { await api('/podcasts/bookmarks/' + id + '/', {note: String(note)}); } catch (e) { toast('Braucht eine Verbindung.'); } + loadDetail(st.open); + } + + async function deleteBookmark(id) { + const ok = typeof customConfirm === 'function' ? await customConfirm('Lesezeichen löschen?') : window.confirm('Lesezeichen löschen?'); + if (!ok) return; + try { await api('/podcasts/bookmarks/' + id + '/delete/', {}); } catch (e) { toast('Braucht eine Verbindung.'); } + loadDetail(st.open); + } + + function playBookmark(id) { + const m = st.detail.bookmarks.filter(x => x.id === id)[0]; + if (!m) return; + const ch = st.detail.chapters.filter(c => c.episode__id === m.episode_id)[0]; + if (ch) play(ch.index, m.position_ms); + } + + function markAll(played) { + const h = host(); + const d = st.detail; + if (!h || !d) return; + h.podcastMarkPlayedAll(JSON.stringify(d.chapters.map(c => c.episode__id)), !!played); + setTimeout(() => loadDetail(st.open), 900); + } + + async function onViewClick(e) { + const t = e.target.closest('button'); + const menu = view.querySelector('.ab-menu'); + if (menu && (!t || t.dataset.ab !== 'menu')) menu.hidden = true; + if (!t) return; + const act = t.dataset.ab; + if (act === 'close') closeBook(); + else if (act === 'menu') { if (menu) menu.hidden = !menu.hidden; } + else if (act === 'go') togglePlay(); + else if (act === 'sleep') sleepAtChapterEnd(); + else if (act === 'mark') addBookmark(); + else if (act === 'unplayed') markAll(false); + else if (act === 'played') markAll(true); + else if (act === 'podcast') { + const ok = typeof customConfirm === 'function' + ? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.') + : true; + if (!ok) return; + setKind(st.open, 'podcast'); + closeBook(); + } + else if (t.dataset.abCh != null) play(Number(t.dataset.abCh)); + else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm)); + else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit)); + else if (t.dataset.abBmDel != null) deleteBookmark(Number(t.dataset.abBmDel)); + } + + shelf.addEventListener('click', e => { + const t = e.target.closest('button, .ab-continue'); + if (!t) return; + if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; } + if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; } + if (t.dataset.abMark != null) { + const id = Number(t.dataset.abMark); + st.picking = false; + st.pickQuery = ''; + // At once on the shelf, before the server has it: a card that fills in when it answers. + const f = ((podcastState() || {}).feeds || []).filter(x => x.id === id)[0]; + if (f) { + st.books = [{feed_id: id, title: f.title, author: f.author || '', artwork_url: f.artwork_url || '', + chapters: f.episode_count || 0, done: 0, total_seconds: 0, heard_seconds: 0, next: null, last_played_at: null}] + .concat((st.books || []).filter(b => b.feed_id !== id)); + } + setKind(id, 'audiobook'); + renderShelf(); + toast('„' + (f ? f.title : 'Podcast') + '“ ist jetzt ein Hörbuch'); + return; + } + if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; } + if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); } + }); + shelf.addEventListener('input', e => { + if (!e.target.classList.contains('ab-pick-q')) return; + st.pickQuery = e.target.value; + const box = shelf.querySelector('.ab-pick-list'); + const pods = ((podcastState() || {}).feeds || []).filter(f => f.kind !== 'audiobook'); + const tmp = document.createElement('div'); + tmp.innerHTML = pickHtml(pods); + const fresh = tmp.querySelector('.ab-pick-list'); + if (box && fresh) box.innerHTML = fresh.innerHTML; + }); + + // ---- following the player ------------------------------------------------------------ + + let pollTimer = 0; + let lastEpisode = null; + function poll() { + clearTimeout(pollTimer); + const p = playerState(); + st.now = p; + const ep = p && p.kind === 'podcast' ? p.episodeId : null; + if (st.open != null && view && view.style.display !== 'none') { + if (ep !== lastEpisode && lastEpisode != null) loadDetail(st.open); // a chapter ended: progress moved + else renderView(); + pollTimer = setTimeout(poll, 1000); + } else if (ep !== lastEpisode && lastEpisode != null && tab.style.display !== 'none') { + loadList(); + } + lastEpisode = ep; + if (st.open == null) pollTimer = setTimeout(poll, 5000); + } + + // ---- when to look ------------------------------------------------------------------------ + + const showTab = window.showTab; + if (typeof showTab === 'function') { + window.showTab = function (name) { + const r = showTab.apply(this, arguments); + if (name === 'books') loadList(); + else if (st.open != null) closeBook(); + return r; + }; + } + + const back = window.__dioraBack; + window.__dioraBack = function () { + if (st.open != null) { + const menu = view && view.querySelector('.ab-menu'); + if (menu && !menu.hidden) { menu.hidden = true; return true; } + closeBook(); + return true; + } + if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; } + return back ? back() : false; + }; + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && st.open != null) closeBook(); + }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden && tab.style.display !== 'none') loadList(); + }); + + // The podcast host fills its state a moment after the page; the shelf needs its abos. + setTimeout(loadList, 300); + setTimeout(poll, 1500); + + // ---- styles ---------------------------------------------------------------------------- + + const style = document.createElement('style'); + style.textContent = [ + '#audiobook-shelf { margin: 0 0 14px; }', + '#audiobook-shelf:empty { display: none; }', + '.ab-h { margin: 18px 0 8px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', + '.ab-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }', + '.ab-link { background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 14px; padding: 8px 0; cursor: pointer; }', + '.ab-empty { color: var(--muted, #9a9a9a); font-size: 13px; line-height: 1.45; margin: 6px 0; }', + '.ab-meta { display: block; color: var(--muted, #9a9a9a); font-size: 12px; line-height: 1.35; }', + '.ab-title { display: block; font-size: 14px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; }', + '.ab-label { color: var(--accent, #e63946); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }', + '.ab-bar { display: block; height: 3px; border-radius: 2px; background: var(--border, #2a2a2a); margin-top: 6px; overflow: hidden; }', + '.ab-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }', + '.ab-cover { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 6px; background: var(--panel, #161616); display: block; }', + '.ab-nocover { display: flex; align-items: center; justify-content: center; font-size: 28px; color: var(--muted, #9a9a9a); }', + '.ab-cover-s { width: 56px; height: 56px; flex: none; }', + '.ab-cover-xs { width: 40px; height: 40px; flex: none; font-size: 16px; }', + '.ab-cover-l { width: 120px; height: 120px; flex: none; }', + '.ab-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; }', + '.ab-continue { display: flex; align-items: center; gap: 12px; padding: 10px; margin: 4px 0 6px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); cursor: pointer; }', + '.ab-play { flex: none; width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 18px; cursor: pointer; }', + '.ab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }', + '.ab-card { display: flex; flex-direction: column; gap: 6px; padding: 0; background: none; border: none; color: inherit; font: inherit; cursor: pointer; text-align: left; min-width: 0; }', + '.ab-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; cursor: pointer; text-align: left; }', + '.ab-icon { background: none; border: none; color: inherit; font: inherit; font-size: 18px; min-width: 40px; min-height: 40px; cursor: pointer; }', + '.ab-pick { margin-top: 10px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; padding: 10px 12px; background: var(--card, #0b0b0b); }', + '.ab-pick-head { display: flex; align-items: center; justify-content: space-between; }', + '.ab-pick-q { width: 100%; box-sizing: border-box; margin: 6px 0; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }', + '.ab-pick-list { max-height: 50vh; overflow-y: auto; }', + // Above the tab bar (150), below the page's dialogs (990): a note is asked for on top of it. + '#ab-view { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; overflow-y: auto; background: var(--bg, #000); color: var(--fg, #eee); padding: 8px 16px 120px; box-sizing: border-box; }', + '#ab-view > * { max-width: 760px; margin-left: auto; margin-right: auto; }', + 'html.ab-open { overflow: hidden; }', + '.ab-top { display: flex; align-items: center; justify-content: space-between; position: relative; }', + '.ab-back { background: none; border: none; color: inherit; font: inherit; font-size: 15px; padding: 10px 0; cursor: pointer; }', + '.ab-menu { position: absolute; right: 16px; z-index: 2; display: flex; flex-direction: column; min-width: 240px; background: var(--raised, #222); border: 1px solid var(--border, #2a2a2a); border-radius: 8px; box-shadow: var(--shadow, 0 6px 24px rgba(0,0,0,.4)); }', + '#ab-view .ab-menu { margin: 0; left: auto; }', + '.ab-menu[hidden] { display: none; }', + '.ab-menu button { background: none; border: none; color: inherit; font: inherit; text-align: left; padding: 12px 14px; cursor: pointer; }', + '.ab-menu button:hover, .ab-row:hover { background: var(--hover, rgba(255,255,255,0.05)); }', + '.ab-hero { display: flex; gap: 16px; align-items: center; margin: 6px auto 14px; }', + '.ab-hero-text { flex: 1; min-width: 0; }', + '.ab-book-title { margin: 0 0 4px; font-size: 19px; line-height: 1.3; }', + '.ab-progress { margin-top: 6px; }', + '.ab-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 6px; }', + '.ab-btn { min-height: 42px; padding: 0 14px; border-radius: 21px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', + '.ab-btn[disabled] { opacity: 0.4; cursor: default; }', + '.ab-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); min-width: 150px; }', + '.ab-num { flex: none; width: 28px; text-align: right; color: var(--muted, #9a9a9a); font-size: 13px; }', + '.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }', + '.ab-chapter.ab-now .ab-title, .ab-chapter.ab-now .ab-num { color: var(--accent, #e63946); }', + '.ab-mark { display: flex; align-items: center; }', + '.ab-mark .ab-row { flex: 1; min-width: 0; }', + '#ab-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }', + '#ab-toast.ab-show { opacity: 1; }', + '@media (max-width: 480px) { .ab-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 10px; } .ab-cover-l { width: 96px; height: 96px; } }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index ce7b8be..bad0ce3 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -76,7 +76,7 @@ const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify', - 'episode_sort', 'episode_filter', 'skip_silence']; + 'episode_sort', 'episode_filter', 'skip_silence', 'kind']; // The account's playback settings (diora-web PodcastSettings) and what each may be. const SKIPS = [5, 10, 15, 20, 30, 45, 60]; const MIN_SPEED = 0.5; @@ -329,6 +329,8 @@ function saveProgress(id, seconds) { forEpisode(state, id, o => { o.position_seconds = seconds; }); + const at = bookIndex(id); + if (at >= 0) { book.chapters[at].position_seconds = seconds; store('book', book); } if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new const body = {episode_id: id, position_seconds: seconds}; // A feed without durations (YouTube): the player's, kept by the server if it has none. @@ -617,6 +619,15 @@ let error = null; let sessionFor = null; // episode the media session shows let mustReach = null; // ms the first seek after loading should have reached + // An audiobook playing (docs/audiobooks.md): {feed_id, chapters: [entry, …]}. Its chapters + // play in order, outside the queue; playing anything else ends it. Kept across reloads. + let book = load('book', null); + if (!book || !Array.isArray(book.chapters)) book = null; + const bookIndex = id => (book ? book.chapters.findIndex(c => c.episode__id === id) : -1); + function setBook(b) { + book = b; + store('book', b); + } const curId = () => (cur ? cur.episode__id : null); const curFeed = () => (cur ? cur.episode__feed__id : null); @@ -646,6 +657,8 @@ } function nextEntry() { + const inBook = bookIndex(curId()); + if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter const q = queueOf(state); const at = q.findIndex(e => e.episode__id === curId()); if (at >= 0) return q[at + 1] || null; @@ -660,6 +673,8 @@ // An episode is done: played, out of the queue (setting). True when the sleep timer says stop. function finished(id) { + const at = bookIndex(id); + if (at >= 0) { book.chapters[at].played = true; store('book', book); } markPlayedAll([id], true, prefs.remove_played); flush(); return sleep.episodeFinished(); @@ -770,9 +785,10 @@ function goTo(e, auto, play, skipped) { const prev = curId(); let stop = false; + if (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) { if (auto || almostEnded()) stop = finished(prev); - else if (skipped && !keepSkipped()) { markPlayedAll([prev], true, prefs.remove_played); flush(); } + else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); } else saveNow(prev, true); } unload(); @@ -1730,6 +1746,8 @@ o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || ''; o.pos = Math.round(posMs()); o.dur = Math.round(durMs()); + const at = bookIndex(cur.episode__id); + if (at >= 0) o.book = {feed_id: book.feed_id, index: at, chapters: book.chapters.length}; o.playing = wantPlay; o.buffering = buffering && wantPlay; if (error) o.error = error; @@ -1824,6 +1842,25 @@ podcastSetPrefValue: (name, value) => setPref(name, Number(value)), podcastPlay: id => playEpisode(id), + // An audiobook's chapter (docs/audiobooks.md), and the rest of the book after it: not queued. + podcastPlayBook(json) { + const b = parse(json, {}) || {}; + const chapters = Array.isArray(b.chapters) ? b.chapters : []; + const e = chapters[Number(b.index) || 0]; + if (!e || e.episode__id == null) return; + setBook({feed_id: b.feed_id, chapters}); + const at = b.position_ms != null && isFinite(Number(b.position_ms)) ? Math.max(0, Number(b.position_ms)) : null; + if (curId() === e.episode__id) { + cur = Object.assign({}, e); + storePlayer(); + if (at != null) seek(at); + resume(); + return; + } + goTo(e, false, false); + if (at != null) { startAt = at; lastPos = at; } + resume(); + }, // Play an episode; one that isn't queued yet is queued first (where the setting says). podcastPlayEpisode(json, currentId) { const ep = parse(json, {}) || {}; @@ -1976,7 +2013,8 @@ // The episode that was in the player before the page was left (not loaded until played). const was = load('player', null); if (was && was.entry && was.entry.episode__id != null) { - cur = entry(was.entry.episode__id) || was.entry; + const at = bookIndex(was.entry.episode__id); + cur = entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry; lastPos = resumeMs(); } diff --git a/static/js/sw.js b/static/js/sw.js index 00c945a..ed72e60 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-v112'; +const CACHE = 'diora-v113'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -40,6 +40,7 @@ const SHELL = [ '/static/js/podcasts-web.js', '/static/js/home.js', '/static/js/feeds.js', + '/static/js/audiobooks.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 992619b..64ddadf 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 6a02899 2026-10-14: web-native/static/native-podcast.js +// Copied from diora-android 7b1d500 2026-10-16: 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. @@ -78,6 +78,12 @@ let renderAfterSwipe = false; let selected = null; // Set of episode ids while choosing several (queue or inbox) let locked = false; // queue locked: no dragging + // Downloads (Downloads.kt; see "downloads" further down): where the host has them. + const DL = !!N.podcastDownloads; + let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; + let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; + // All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. + let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {}; try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {} try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {} // Opening into the podcasts (start page): that list. @@ -96,6 +102,9 @@ return Math.max(n, total - Math.max(0, sent - n)); } const feeds = () => state.feeds || []; + // Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here. + const isBook = f => !!f && f.kind === 'audiobook'; + const podcastFeeds = () => feeds().filter(f => !isBook(f)); const settings = () => state.settings || {}; const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0); // The account's speed presets (null there: the default list). @@ -212,6 +221,33 @@ t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); } + // GET from our server; a status other than 2xx throws (as a network error does). + async function getJson(url) { + const r = await fetch(url); + if (!r.ok) throw new Error(r.status); + return r.json(); + } + + // A full sheet (the player, shownotes, a podcast's info, statistics, downloads): the + // overlay — one id, the CSS and the TV's focus know it — its panel, the ⌄ at the top, + // and a tap beside the panel closes it. The caller fills the panel (top first) and + // puts the sheet on the page. + function podSheet(cls, closeLabel, onClose, dialogLabel) { + const sheet = el('div'); + sheet.id = 'nui-pod-player'; + if (cls) sheet.className = cls; + const panel = el('div', 'nui-pod-player-panel'); + if (dialogLabel) { + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', dialogLabel); + } + const top = el('div', 'nui-pod-player-top'); + top.append(button('⌄', closeLabel, onClose)); + sheet.append(panel); + sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); }); + return {sheet, panel, top}; + } + // Feed titles sometimes arrive with HTML entities still in them (&). const decoder = document.createElement('textarea'); function plain(s) { @@ -235,15 +271,30 @@ id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id, art: o.episode__artwork_url, feedArt: o.episode__feed__artwork_url || feedArtOf(o.episode__feed__id), duration: o.episode__duration_seconds, pos: o.position_seconds, date: o.episode__pub_date, raw: o, + yt: isYouTube(o.episode__audio_url), }; } return { id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id, art: o.artwork_url, feedArt: o.feed__artwork_url || feedArtOf(o.feed__id) || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds, pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o, + yt: isYouTube(o.audio_url), }; } + // "Kapitel 7/24 · " while a chapter of an audiobook plays (playerState().book). + function bookLine(p) { + const b = p && p.book; + if (!b || !b.chapters) return ''; + return `Kapitel ${b.index + 1}/${b.chapters}` + (b.title || p.feed ? ' · ' + plain(b.title || p.feed) : ''); + } + + // A video of a subscribed YouTube channel (diora-web docs/youtube.md): its sound + // is found on this device; the feed knows no length until it has played. + function isYouTube(url) { + return /^https:\/\/www\.youtube\.com\/watch\?v=/.test(url || ''); + } + function feedArtOf(id) { const f = id ? feeds().find(x => x.id === id) : null; return f ? f.artwork_url : ''; @@ -1118,6 +1169,7 @@ // A played episode, or a place past the feed's (often wrong) length: just the length. const started = e.pos > 30 && !e.played && e.pos < e.duration - 30; if (e.duration) meta.push(started ? minutes(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId))); + if (e.yt) meta.push('YouTube'); if (opts.showDate && e.date) meta.push(day(e.date)); if (opts.showFeed && e.feed) meta.push(e.feed); const metaEl = el('div', 'nui-pod-meta', meta.join(' · ')); @@ -1141,7 +1193,7 @@ 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); + rowGestures(row, menu, swipes && (act => swipeDone(e, swipes, act, opts.filters || [])), swipes); return row; } @@ -1346,8 +1398,8 @@ const SWIPE_ACTIONS = [ {id: 'none', label: 'Nichts', icon: ''}, {id: 'queue', label: 'Einreihen', 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'; }, + // Where there are files. + {id: 'dequeue', label: DL ? 'Aus Warteschlange · Datei bleibt' : 'Aus der Warteschlange', icon: '✕', svg: 'listx'}, {id: 'download', label: 'Herunterladen', icon: '↓', dl: true}, {id: 'delete', label: 'Datei löschen', hint: 'nur die Datei auf diesem Gerät', icon: '✕', svg: 'trash', dl: true}, @@ -1739,9 +1791,13 @@ function renderFeeds() { const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0]; - const items = feeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); + const items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); + const books = feeds().length - items.length; + const booksHint = () => el('div', 'nui-pod-hint', + `${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: in Bücher → Hörbücher`); if (!items.length) { empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); + if (books) root.append(booksHint()); return; } const head = el('div', 'nui-pod-head'); @@ -1751,6 +1807,7 @@ const list = el('div', 'nui-pod-list'); for (const f of items) list.append(feedRow(f)); root.append(list); + if (books) root.append(booksHint()); } async function openFeed(f) { @@ -1759,9 +1816,7 @@ render(); scrollTop(0); try { - const r = await fetch(`/podcasts/feeds/${f.id}/episodes/`); - if (!r.ok) throw new Error(r.status); - const data = await r.json(); + const data = await getJson(`/podcasts/feeds/${f.id}/episodes/`); if (feedView && feedView.feed.id === f.id) { feedView = {feed: Object.assign({}, f, data.feed), episodes: data.episodes}; render(); @@ -1787,9 +1842,9 @@ info.append(el('div', 'nui-pod-title', plain(f.title))); if (f.author) info.append(el('div', 'nui-pod-meta', plain(f.author))); head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn')); - if (feedHasSettings(live)) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear')); + if (feedHasSettings()) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear')); root.append(head); - const line = feedHasSettings(live) ? feedSummary(f, live) : ''; + const line = feedHasSettings() ? feedSummary(f, live) : ''; if (line) { const s = el('button', 'nui-pod-feedsum', line); s.type = 'button'; @@ -1805,7 +1860,8 @@ renderFeedEpisodes(f); } - function feedHasSettings(live) { + // The host keeps per-podcast settings (the browser and the app do; an old host doesn't). + function feedHasSettings() { return !!N.podcastFeedSettings || !!N.podcastAutoQueue || !!N.podcastFeedPrefs; } @@ -1920,6 +1976,13 @@ .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, active: (own.volume || 1) === v, action: () => set('volume', v)})))}, ); } + // An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md). + if ('kind' in live && N.podcastFeedSettings) { + const book = isBook(live); + items.push({icon: '▤', label: book ? 'Hörbuch (in Bücher)' : 'Als Hörbuch', + hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel in der Bibliothek, nicht in „Neu“', + active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})}); + } // Skip silence for this podcast (server), where this player can skip silence at all. if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) { const mine = live.skip_silence; @@ -2054,12 +2117,8 @@ const live = Object.assign({}, f, feedView && feedView.feed.id === f.id ? feedView.feed : {}, feeds().find(x => x.id === f.id) || {}); closeNotes(); - notesSheet = el('div'); - notesSheet.id = 'nui-pod-player'; - notesSheet.className = 'nui-pod-notes-sheet nui-pod-info'; - const panel = el('div', 'nui-pod-player-panel'); - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Schließen', closeNotes)); + const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-info', 'Schließen', closeNotes); + notesSheet = made; const head = el('div', 'nui-pod-feedhead'); const title = el('div', 'nui-pod-main'); title.append(el('div', 'nui-pod-title', plain(live.title))); @@ -2116,8 +2175,6 @@ (live.error_count > 1 ? ` · ${live.error_count}× nacheinander` : ''))); } panel.append(facts); - notesSheet.append(panel); - notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); document.body.append(notesSheet); } @@ -2185,7 +2242,7 @@ epSort = localStorage.getItem('nui_pod_epsort') || epSort; } catch (e) {} if (!Array.isArray(epFilters)) epFilters = []; - epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || !!N.podcastDownloads))); + epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL))); if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new'; let epList = null; // {key, items, total, loading, error} @@ -2336,17 +2393,11 @@ function openStats() { closeStats(); - statsSheet = el('div'); - statsSheet.id = 'nui-pod-player'; - statsSheet.className = 'nui-pod-notes-sheet'; - const panel = el('div', 'nui-pod-player-panel'); - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Schließen', closeStats)); + const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet', 'Schließen', closeStats); + statsSheet = made; const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-stats'); panel.append(top, el('div', 'nui-pod-player-title', 'Hörstatistik'), tabs, body); - statsSheet.append(panel); - statsSheet.addEventListener('click', ev => { if (ev.target === statsSheet) closeStats(); }); document.body.append(statsSheet); const ranges = [{days: 30, label: '30 Tage'}, {days: 365, label: 'Jahr'}, {days: 0, label: 'Gesamt'}]; const show = async range => { @@ -2355,9 +2406,7 @@ body.append(el('div', 'nui-pod-meta', 'Wird geladen …')); let d = null; try { - const r = await fetch('/podcasts/stats/' + (range.days ? `?days=${range.days}` : '')); - if (!r.ok) throw new Error(r.status); - d = await r.json(); + d = await getJson('/podcasts/stats/' + (range.days ? `?days=${range.days}` : '')); } catch (e) { body.textContent = ''; body.append(el('div', 'nui-pod-meta', 'Die Statistik braucht eine Verbindung.')); @@ -2555,7 +2604,7 @@ const test = matcher(q); const s = searchResults = { q, - feeds: feeds().filter(f => test(fold(f.title) + ' ' + fold(f.author))), + feeds: podcastFeeds().filter(f => test(fold(f.title) + ' ' + fold(f.author))), eps: {items: [], total: 0, loading: true}, dir: {items: [], loading: true}, }; @@ -2569,9 +2618,7 @@ s.eps.loading = true; if (more) render(); try { - const r = await fetch(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}¬es=1&limit=30&offset=${offset}`); - if (!r.ok) throw new Error(r.status); - const d = await r.json(); + const d = await getJson(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}¬es=1&limit=30&offset=${offset}`); s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { if (more) { @@ -3026,7 +3073,7 @@ sk.setAttribute('aria-label', sk.title); } mini.querySelector('.nui-mini-track').textContent = (player.buffering && player.playing ? 'Lädt …' - : (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : '')) + : (bookLine(player) || player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : '')) + sleepSuffix(); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = player.playing ? '❚❚' : '▶'; @@ -3068,9 +3115,7 @@ async function loadDetails(id) { try { - const r = await fetch(`/podcasts/episodes/${id}/`); - if (!r.ok) throw new Error(r.status); - const d = await r.json(); + const d = await getJson(`/podcasts/episodes/${id}/`); keepDetails(id, d); return d; } catch (e) { @@ -3254,9 +3299,7 @@ let t = transcripts.get(id); if (!t) { try { - const r = await fetch(`/podcasts/episodes/${id}/transcript/`); - if (!r.ok) throw new Error(r.status); - t = await r.json(); + t = await getJson(`/podcasts/episodes/${id}/transcript/`); transcripts.set(id, t); } catch (e) { body.textContent = ''; @@ -3301,12 +3344,8 @@ function openEpisode(e, ctx) { closeNotes(); ctx = ctx || {}; - notesSheet = el('div'); - notesSheet.id = 'nui-pod-player'; - 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)); + const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-episode', 'Schließen', closeNotes); + notesSheet = made; const head = el('div', 'nui-pod-ephead'); head.append(art(e.art, 'big', e.feedArt, e.feed)); const info = el('div', 'nui-pod-main'); @@ -3339,8 +3378,6 @@ const actions = el('div', 'nui-pod-epactions'); const box = el('div', 'nui-pod-notesbox'); panel.append(top, head, playBtn, actions, box); - notesSheet.append(panel); - notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); document.body.append(notesSheet); epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')}; episodeScreenParts(); @@ -3421,12 +3458,8 @@ if (player.kind !== 'podcast') return; closePlayer(); sheetKind = 'podcast'; - sheet = el('div'); - sheet.id = 'nui-pod-player'; - sheet.innerHTML = ''; - const panel = sheet.firstChild; - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Player schließen', closePlayer)); + const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Player'); + sheet = made; panel.append(top, art(player.artwork, 'player', null, player.feed)); const chapBox = el('div', 'nui-pod-chapbox'); chapBox.hidden = true; @@ -3473,7 +3506,6 @@ panel.append(extra); const notes = el('div', 'nui-pod-notesbox'); panel.append(notes); - sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); sheetEpisode = player.episodeId; showDetails(notes, player.episodeId, true); @@ -3494,12 +3526,8 @@ if (!r) return; closePlayer(); sheetKind = 'radio'; - sheet = el('div'); - sheet.id = 'nui-pod-player'; - sheet.innerHTML = ''; - const panel = sheet.firstChild; - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Player schließen', closePlayer)); + const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Radio'); + sheet = made; const cover = art(r.logo, 'player'); cover.classList.add('nui-radio-art'); panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), @@ -3517,7 +3545,6 @@ extra.append(stations); if (N.playerTransfer) extra.append(devicesButton()); panel.append(controls, extra); - sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); renderRadioPlayer(); } @@ -3590,7 +3617,7 @@ } markChapter(sheet, (player.pos || 0) / 1000); sheet.querySelector('.nui-pod-player-title').textContent = player.title || ''; - sheet.querySelector('.nui-pod-player-feed').textContent = player.feed || ''; + sheet.querySelector('.nui-pod-player-feed').textContent = bookLine(player) || player.feed || ''; const range = sheet.querySelector('.nui-pod-seek'); const dur = Math.round((player.dur || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000); @@ -3913,14 +3940,9 @@ closePlayer(); sheetKind = 'remote'; const onRadio = r.kind === 'radio'; - sheet = el('div'); - sheet.id = 'nui-pod-player'; - sheet.className = 'nui-pod-remote'; + const {sheet: made, panel, top} = podSheet('nui-pod-remote', 'Player schließen', closePlayer, 'Player'); + sheet = made; sheet.dataset.remote = remoteKey(r); - sheet.innerHTML = ''; - const panel = sheet.firstChild; - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Player schließen', closePlayer)); const cover = art(onRadio ? r.station && r.station.logo : r.artwork, 'player'); if (onRadio) cover.classList.add('nui-radio-art'); panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed nui-pod-remote-line')); @@ -3971,7 +3993,6 @@ panel.append(notes); showDetails(notes, r.episodeId, false); } - sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); renderRemotePlayer(); } @@ -4008,11 +4029,6 @@ // menus and the settings sheet get their entries. The app downloads the // first episodes of the queue by itself and tells us via __dioraDownloads. - const DL = !!N.podcastDownloads; - let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; - let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; - // All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. - let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {}; // How many of the queue's first episodes load by themselves; 0 = all of them. const DL_COUNTS = [1, 2, 3, 5, 10, 0]; const dlCountLabel = n => (n === 0 ? 'Alle' : n === 1 ? '1 Folge' : n + ' Folgen'); @@ -4245,16 +4261,10 @@ function openDownloads() { if (!DL) return; closeDownloads(); - dlSheet = el('div'); - dlSheet.id = 'nui-pod-player'; - dlSheet.className = 'nui-pod-notes-sheet nui-pod-dls'; - const panel = el('div', 'nui-pod-player-panel'); - const top = el('div', 'nui-pod-player-top'); - top.append(button('⌄', 'Schließen', closeDownloads)); + const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-dls', 'Schließen', closeDownloads); + dlSheet = made; dlBody = el('div', 'nui-pod-dls-body'); panel.append(top, el('div', 'nui-pod-player-title', 'Downloads'), dlBody); - dlSheet.append(panel); - dlSheet.addEventListener('click', ev => { if (ev.target === dlSheet) closeDownloads(); }); document.body.append(dlSheet); renderDownloads(true); } diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index d072b24..fd2cd1c 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 88b4842 2026-10-14: the podcast parts of web-native/static/native.css +/* Copied from diora-android 7b1d500 2026-10-16: 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. */ @@ -194,6 +194,8 @@ html.nui-one-player #nui-mini { display: none !important; } .nui-pod-count-queue { background: var(--accent); color: var(--on-accent, #fff); } .nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); } +/* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */ +.nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; } .nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; } .nui-pod-list { display: flex; flex-direction: column; } @@ -231,7 +233,7 @@ html.nui-one-player #nui-mini { display: none !important; } .nui-pod-art img { width: 100%; height: 100%; object-fit: cover; display: block; } .nui-pod-art-big { width: 64px; height: 64px; } .nui-pod-art-mini { width: 44px; height: 44px; margin-left: 10px; } -.nui-pod-art-player { width: min(72vw, 320px); height: min(72vw, 320px); border-radius: 8px; align-self: center; } +.nui-pod-art-player { width: min(64vw, 300px); height: min(64vw, 300px); border-radius: 8px; align-self: center; } .nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .nui-pod-title { @@ -377,7 +379,6 @@ body.eink .nui-pod-row { background: #fff; } body.eink .nui-pod-row:active { background: #ddd; } body.eink .nui-pod-count { background: #ddd; color: #000; } body.eink .nui-pod-tab.active { border-bottom-color: #000; color: #000; } -body.eink .nui-pod-tab.active .nui-pod-count { background: #000; color: #fff; } body.eink .nui-pod-progress { background: #ccc; } body.eink .nui-pod-progress > span, body.eink .nui-pod-mini-bar span { background: #000; } @@ -748,7 +749,6 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; } .nui-pod-chaplist { position: relative; max-height: 168px; overflow-y: auto; margin: 0 -4px 4px -10px; padding: 0 4px 0 10px; border-top: 1px solid var(--border); } .nui-pod-chaplist .nui-pod-chapter { padding: 8px 4px; } .nui-pod-chaplist .nui-pod-chapter:last-child { border-bottom: none; } -.nui-pod-art-player { width: min(64vw, 300px); height: min(64vw, 300px); } .nui-pod-withchapters .nui-pod-art-player { width: min(46vw, 220px); height: min(46vw, 220px); } /* the downloads overview: the finished ones as a list */ @@ -796,185 +796,3 @@ body.eink .nui-pod-facts a { text-decoration-color: #000; } background: rgba(0, 0, 0, 0.55); color: #ddd; } - -/* diora Reader (books only, native.js): no read-aloud, no cloud import. */ -.nui-reader-app #reader-tts-btn, -.nui-reader-app #reader-tts-lang, -.nui-reader-app button[onclick*="toggleCloudImport"], -.nui-reader-app #cloud-browser { display: none !important; } - -/* Datenverbrauch (native-ui.js showDataUsage) */ -.nui-usage { padding: 4px 16px 12px; } -.nui-usage-chips { display: flex; gap: 6px; margin-bottom: 12px; } -.nui-usage-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; } -.nui-usage-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); } -.nui-usage-total { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; margin-bottom: 10px; } -.nui-usage-total b { color: var(--accent, #e63946); } -.nui-usage-table { width: 100%; border-collapse: collapse; font-size: 13px; } -.nui-usage-table th { text-align: right; font-weight: 400; color: var(--nui-muted, #9a9a9a); padding: 2px 0 6px; } -.nui-usage-table td { padding: 7px 0; border-top: 1px solid var(--border, #2a2a2a); vertical-align: top; } -.nui-usage-table td + td { text-align: right; white-space: nowrap; padding-left: 10px; } -.nui-usage-bar { display: block; height: 3px; margin-top: 4px; background: var(--raised, #2a2a2a); border-radius: 2px; overflow: hidden; } -.nui-usage-bar > span { display: block; height: 100%; background: var(--accent, #e63946); } -.nui-usage-note { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 10px 0 0; } -body.eink .nui-usage-bar > span { background: #000; } -body.eink .nui-usage-total b { color: #000; } - -/* ---- sidebar layout (html.nui-sidebar, native-radio.js): the menu as a rail on the left, - the player bars at the bottom of the page. Always on a TV, a choice in the app menu elsewhere. ---- */ - -.nui-rail-brand, .nui-rail-menu { display: none; } - -html.nui-sidebar { --nui-rail-w: 200px; } -html.nui-sidebar body { padding-left: var(--nui-rail-w); } -html.nui-sidebar.nui-reading body { padding-left: 0; } -html.nui-sidebar #nui-dock { left: var(--nui-rail-w); } -html.nui-sidebar #nui-tabbar { - position: fixed; - left: 0; - top: 0; - bottom: 0; - width: var(--nui-rail-w); - flex-direction: column; - padding: 18px 0 12px; - background: var(--panel, #0b0b0b); - border-right: 1px solid var(--border); - overflow-y: auto; -} -html.nui-sidebar #nui-tabbar button { - flex: 0 0 auto; - height: 46px; - flex-direction: row; - justify-content: flex-start; - gap: 14px; - padding: 0 22px; - font-size: 15px; - text-align: left; -} -html.nui-sidebar #nui-tabbar .nui-tab-icon { width: 22px; text-align: center; font-size: 19px; } -html.nui-sidebar #nui-tabbar button.nui-active { background: var(--hover, #181818); box-shadow: inset 3px 0 0 var(--accent); } -html.nui-sidebar .nui-rail-brand { - display: block; - padding: 0 22px 18px; - color: var(--accent); - font-weight: 700; - font-size: 22px; - letter-spacing: 0.08em; -} -html.nui-sidebar .nui-rail-menu { display: flex; margin-top: auto; } -/* Home's own wordmark: the rail has it already. */ -html.nui-sidebar #tab-home .home-brand { display: none; } -body.eink #nui-tabbar { background: #fff; } -/* A narrow screen (a phone upright): the rail shows its signs only. */ -@media (max-width: 640px) { - html.nui-sidebar { --nui-rail-w: 60px; } - html.nui-sidebar #nui-tabbar button { padding: 0; justify-content: center; font-size: 0; gap: 0; } - html.nui-sidebar #nui-tabbar .nui-tab-icon { font-size: 21px; } - html.nui-sidebar .nui-rail-brand { padding: 0 0 14px; text-align: center; font-size: 16px; letter-spacing: 0; overflow: hidden; } -} - -/* ---- television (html.nui-tv, native-tv.js): a focus that the remote moves ---- */ - -html.nui-tv { scroll-padding: 24px 0 calc(var(--nui-dock-h, 0px) + 24px); } -html.nui-tv .nui-tv-focus { - outline: 3px solid var(--accent) !important; - outline-offset: 2px; - border-radius: 6px; -} -html.nui-tv #nui-tabbar button.nui-tv-focus { outline-offset: -3px; background: #222; } -html.nui-tv :focus:not(.nui-tv-focus) { outline: none; } - -/* Books and feeds hidden on the TV (unless switched on in the app menu); Home's - sections of them: native-tv.js (:has, which the e-readers' old WebView lacks). */ -html.nui-noread #nui-tabbar button[data-tab="books"], -html.nui-noread #nui-tabbar button[data-tab="feeds"] { display: none; } - -/* ---- screensaver (native-saver.js): what plays, large, drifting slowly ---- */ - -#nui-saver { - position: fixed; - inset: 0; - z-index: 3000; - display: none; - overflow: hidden; - background: #000; - color: #fff; - cursor: none; -} -#nui-saver.nui-on { display: block; animation: nui-saver-in 1.2s ease-out; } -@keyframes nui-saver-in { from { opacity: 0; } to { opacity: 1; } } -.nui-saver-bg { - position: absolute; - inset: -10%; - background: center / cover no-repeat; - filter: blur(60px) brightness(0.32) saturate(1.3); -} -.nui-saver-stage { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - gap: 5vw; - padding: 8vh 10vw; - transition: transform 44s ease-in-out; -} -.nui-saver-art { - flex: 0 0 auto; - width: 34vmin; - height: 34vmin; - border-radius: 3vmin; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #161616; - box-shadow: 0 2vmin 8vmin rgba(0, 0, 0, 0.6); -} -.nui-saver-art img { width: 100%; height: 100%; object-fit: cover; } -.nui-saver-art span { font-size: 16vmin; color: var(--accent); line-height: 1; } -.nui-saver-text { flex: 0 1 52vw; min-width: 0; display: flex; flex-direction: column; gap: 1.6vmin; } -.nui-saver-kicker, .nui-saver-sub, .nui-saver-chapter, .nui-saver-left { font-family: var(--nui-sans); letter-spacing: normal; } -.nui-saver-kicker { color: var(--accent); font-size: 2.6vmin; font-weight: 600; text-transform: uppercase; } -.nui-saver-title { - font-size: 5.4vmin; - font-weight: 700; - line-height: 1.2; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} -.nui-saver-sub { font-size: 3.4vmin; color: #ddd; line-height: 1.3; } -.nui-saver-chapter { font-size: 3vmin; color: var(--accent); } -.nui-saver-progress { height: 0.6vmin; border-radius: 1vmin; background: rgba(255, 255, 255, 0.18); overflow: hidden; margin-top: 1vmin; } -.nui-saver-progress span { display: block; height: 100%; background: var(--accent); transition: width 4s linear; } -.nui-saver-left { font-size: 2.6vmin; color: #bbb; } -#nui-saver [hidden] { display: none; } -.nui-saver-clock { - position: absolute; - right: 5vw; - bottom: 6vh; - font-size: 7vmin; - font-weight: 200; - color: rgba(255, 255, 255, 0.75); - font-variant-numeric: tabular-nums; - transition: transform 44s ease-in-out; -} -/* Ambient: its own picture full screen; the saver only adds the clock and the sound world. */ -html.nui-saver-ambient #tab-ambient { display: block !important; } -html.nui-saver-ambient #ambient-frame { - position: fixed !important; - inset: 0; - width: 100vw !important; - height: 100vh !important; - z-index: 2999; - border: 0; -} -html.nui-saver-ambient #nui-saver { background: transparent; } -html.nui-saver-ambient .nui-saver-bg, -html.nui-saver-ambient .nui-saver-art, -html.nui-saver-ambient .nui-saver-progress, -html.nui-saver-ambient .nui-saver-left { display: none; } -html.nui-saver-ambient .nui-saver-stage { align-items: flex-start; justify-content: flex-start; } -html.nui-saver-ambient .nui-saver-title { font-size: 3.4vmin; font-weight: 300; letter-spacing: 0.1em; } diff --git a/templates/radio/player.html b/templates/radio/player.html index d87d291..85a60ec 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -383,4 +383,5 @@ + {% endblock %}