Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m52s
Test / e2e (push) Successful in 1m59s

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/ (+<id>/), /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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 13:05:00 +02:00
parent a3673a6042
commit 5be1b3fbaf
14 changed files with 1283 additions and 283 deletions

112
docs/audiobooks.md Normal file
View file

@ -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/<id>/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/<feed_id>/` → `{"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/<id>/ {"note"}` → `{"ok", "bookmark": bm}`
- `POST /podcasts/bookmarks/<id>/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 · <book>" when it plays one.

212
podcasts/audiobooks.py Normal file
View file

@ -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})

View file

@ -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'],
},
),
]

View file

@ -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')

135
podcasts/test_audiobooks.py Normal file
View file

@ -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 = ('<?xml version="1.0"?><rss version="2.0"><channel><title>Das Buch</title>'
'<item><guid>neu</guid><title>Kapitel 11</title><pubDate>Mon, 05 Jan 2026 10:00:00 GMT</pubDate>'
'<enclosure url="https://example.org/11.mp3" type="audio/mpeg" length="1"/></item></channel></rss>')
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)

View file

@ -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),

View file

@ -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/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
path('feeds/<int:pk>/move/', views.move_feed, name='podcast_move_feed'),
path('audiobooks/', audiobooks.audiobook_list, name='podcast_audiobooks'),
path('audiobooks/<int:feed_id>/', audiobooks.audiobook_detail, name='podcast_audiobook'),
path('bookmarks/', audiobooks.bookmark_add, name='podcast_bookmark_add'),
path('bookmarks/<int:pk>/', audiobooks.bookmark_update, name='podcast_bookmark_update'),
path('bookmarks/<int:pk>/delete/', audiobooks.bookmark_delete, name='podcast_bookmark_delete'),
path('feeds/<int:pk>/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'),

View file

@ -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(

600
static/js/audiobooks.js Normal file
View file

@ -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/<id>/
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 =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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
? '<img class="' + cls + '" src="' + esc(art) + '" alt="" loading="lazy">'
: '<span class="' + cls + ' ab-nocover">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
}
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 += '<div class="ab-continue" data-ab-open="' + go.feed_id + '">' +
cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') +
'<div class="ab-card-text"><div class="ab-label">Weiterhören</div>' +
'<div class="ab-title">' + esc(go.title) + '</div>' +
'<div class="ab-meta">' + esc(progressText(go)) + '</div>' +
'<div class="ab-bar"><span style="width:' + pct(go) + '%"></span></div></div>' +
'<button type="button" class="ab-play" data-ab-go="' + go.feed_id + '" aria-label="Weiterhören">▶</button></div>';
}
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
if (books.length) {
html += '<div class="ab-grid">' + books.map(b =>
'<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'ab-cover') +
'<span class="ab-card-text"><span class="ab-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>').join('') + '</div>';
} else {
html += '<p class="ab-empty">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.</p>';
}
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 '<div class="ab-pick"><div class="ab-pick-head"><strong>Welcher Podcast ist ein Hörbuch?</strong>' +
'<button type="button" class="ab-icon" data-ab="pick-close" aria-label="Schließen">✕</button></div>' +
'<input type="search" class="ab-pick-q" placeholder="Abo suchen …" value="' + esc(st.pickQuery) + '">' +
'<div class="ab-pick-list">' + found.map(f =>
'<button type="button" class="ab-row" data-ab-mark="' + f.id + '">' + cover(f.artwork_url, f.title, 'ab-cover ab-cover-xs') +
'<span class="ab-card-text"><span class="ab-title">' + esc(f.title) + '</span>' +
(f.author ? '<span class="ab-meta">' + esc(f.author) + '</span>' : '') + '</span></button>').join('') +
(found.length ? '' : '<p class="ab-empty">Kein Abo gefunden.</p>') + '</div></div>';
}
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 = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; 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 =
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' +
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
'<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
(b.author ? '<div class="ab-meta">' + esc(b.author) + '</div>' : '') +
'<div class="ab-meta ab-progress">' + esc(progressText(b)) + '</div>' +
'<div class="ab-bar"><span style="width:' + pct(b) + '%"></span></div></div></div>' +
'<div class="ab-actions">' +
'<button type="button" class="ab-btn ab-primary" data-ab="go">' + goLabel(b, now) + '</button>' +
'<button type="button" class="ab-btn" data-ab="sleep"' + (now ? '' : ' disabled') + '>☾ Kapitelende</button>' +
'<button type="button" class="ab-btn" data-ab="mark"' + (now ? '' : ' disabled') + '>+ Lesezeichen</button></div>' +
(marks.length ? '<h3 class="ab-h">Lesezeichen</h3><div class="ab-marks">' + marks.map(m =>
'<div class="ab-mark"><button type="button" class="ab-row" data-ab-bm="' + m.id + '">' +
'<span class="ab-card-text"><span class="ab-meta">' + esc(titleOf(m.episode_id)) + ' · ' + clock(m.position_ms) + '</span>' +
'<span class="ab-title">' + (m.note ? esc(m.note) : '<span class="ab-meta">ohne Notiz</span>') + '</span></span></button>' +
'<button type="button" class="ab-icon" data-ab-bm-edit="' + m.id + '" aria-label="Notiz ändern">✎</button>' +
'<button type="button" class="ab-icon" data-ab-bm-del="' + m.id + '" aria-label="Löschen">✕</button></div>').join('') + '</div>' : '') +
'<h3 class="ab-h">Kapitel</h3>' +
(d ? '<div class="ab-chapters">' + d.chapters.map(c => chapterHtml(c, now)).join('') + '</div>'
: '<p class="ab-empty">Wird geladen …</p>');
}
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 '<button type="button" class="ab-row ab-chapter' + (c.played ? ' ab-done' : '') + (playing ? ' ab-now' : '') +
'" data-ab-ch="' + c.index + '"><span class="ab-num">' + (playing ? '♪' : (c.index + 1)) + '</span>' +
'<span class="ab-card-text"><span class="ab-title">' + esc(c.episode__title) + '</span>' +
'<span class="ab-meta">' + esc(meta) + '</span></span></button>';
}
// 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);
})();

View file

@ -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();
}

View file

@ -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',

View file

@ -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 (&amp;).
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 · <book>" 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)}&notes=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)}&notes=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 = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
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 = '<div class="nui-pod-player-panel" role="dialog" aria-label="Radio"></div>';
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 = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
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);
}

View file

@ -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; }

View file

@ -383,4 +383,5 @@
<script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script>
<script src="/static/js/audiobooks.js"></script>
{% endblock %}