Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
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:
parent
a3673a6042
commit
5be1b3fbaf
14 changed files with 1283 additions and 283 deletions
112
docs/audiobooks.md
Normal file
112
docs/audiobooks.md
Normal 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
212
podcasts/audiobooks.py
Normal 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})
|
||||
35
podcasts/migrations/0019_audiobooks.py
Normal file
35
podcasts/migrations/0019_audiobooks.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
135
podcasts/test_audiobooks.py
Normal 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)
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
600
static/js/audiobooks.js
Normal 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 =>
|
||||
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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);
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 6a02899 2026-10-14: web-native/static/native-podcast.js
|
||||
// Copied from diora-android 7b1d500 2026-10-16: web-native/static/native-podcast.js
|
||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||||
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||
|
||||
|
|
@ -78,6 +78,12 @@
|
|||
let renderAfterSwipe = false;
|
||||
let selected = null; // Set of episode ids while choosing several (queue or inbox)
|
||||
let locked = false; // queue locked: no dragging
|
||||
// Downloads (Downloads.kt; see "downloads" further down): where the host has them.
|
||||
const DL = !!N.podcastDownloads;
|
||||
let downloads = DL ? parse(N.podcastDownloads(), {}) : {};
|
||||
let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {};
|
||||
// All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round.
|
||||
let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {};
|
||||
try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {}
|
||||
try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {}
|
||||
// Opening into the podcasts (start page): that list.
|
||||
|
|
@ -96,6 +102,9 @@
|
|||
return Math.max(n, total - Math.max(0, sent - n));
|
||||
}
|
||||
const feeds = () => state.feeds || [];
|
||||
// Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here.
|
||||
const isBook = f => !!f && f.kind === 'audiobook';
|
||||
const podcastFeeds = () => feeds().filter(f => !isBook(f));
|
||||
const settings = () => state.settings || {};
|
||||
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
|
||||
// The account's speed presets (null there: the default list).
|
||||
|
|
@ -212,6 +221,33 @@
|
|||
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
||||
}
|
||||
|
||||
// GET from our server; a status other than 2xx throws (as a network error does).
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// A full sheet (the player, shownotes, a podcast's info, statistics, downloads): the
|
||||
// overlay — one id, the CSS and the TV's focus know it — its panel, the ⌄ at the top,
|
||||
// and a tap beside the panel closes it. The caller fills the panel (top first) and
|
||||
// puts the sheet on the page.
|
||||
function podSheet(cls, closeLabel, onClose, dialogLabel) {
|
||||
const sheet = el('div');
|
||||
sheet.id = 'nui-pod-player';
|
||||
if (cls) sheet.className = cls;
|
||||
const panel = el('div', 'nui-pod-player-panel');
|
||||
if (dialogLabel) {
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-label', dialogLabel);
|
||||
}
|
||||
const top = el('div', 'nui-pod-player-top');
|
||||
top.append(button('⌄', closeLabel, onClose));
|
||||
sheet.append(panel);
|
||||
sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); });
|
||||
return {sheet, panel, top};
|
||||
}
|
||||
|
||||
// Feed titles sometimes arrive with HTML entities still in them (&).
|
||||
const decoder = document.createElement('textarea');
|
||||
function plain(s) {
|
||||
|
|
@ -235,15 +271,30 @@
|
|||
id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id,
|
||||
art: o.episode__artwork_url, feedArt: o.episode__feed__artwork_url || feedArtOf(o.episode__feed__id),
|
||||
duration: o.episode__duration_seconds, pos: o.position_seconds, date: o.episode__pub_date, raw: o,
|
||||
yt: isYouTube(o.episode__audio_url),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id,
|
||||
art: o.artwork_url, feedArt: o.feed__artwork_url || feedArtOf(o.feed__id) || (feedView && feedView.feed.artwork_url),
|
||||
duration: o.duration_seconds, pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o,
|
||||
yt: isYouTube(o.audio_url),
|
||||
};
|
||||
}
|
||||
|
||||
// "Kapitel 7/24 · <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)}¬es=1&limit=30&offset=${offset}`);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const d = await r.json();
|
||||
const d = await getJson(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}¬es=1&limit=30&offset=${offset}`);
|
||||
s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0};
|
||||
} catch (e) {
|
||||
if (more) {
|
||||
|
|
@ -3026,7 +3073,7 @@
|
|||
sk.setAttribute('aria-label', sk.title);
|
||||
}
|
||||
mini.querySelector('.nui-mini-track').textContent = (player.buffering && player.playing ? 'Lädt …'
|
||||
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''))
|
||||
: (bookLine(player) || player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''))
|
||||
+ sleepSuffix();
|
||||
const t = mini.querySelector('.nui-mini-toggle');
|
||||
t.textContent = player.playing ? '❚❚' : '▶';
|
||||
|
|
@ -3068,9 +3115,7 @@
|
|||
|
||||
async function loadDetails(id) {
|
||||
try {
|
||||
const r = await fetch(`/podcasts/episodes/${id}/`);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const d = await r.json();
|
||||
const d = await getJson(`/podcasts/episodes/${id}/`);
|
||||
keepDetails(id, d);
|
||||
return d;
|
||||
} catch (e) {
|
||||
|
|
@ -3254,9 +3299,7 @@
|
|||
let t = transcripts.get(id);
|
||||
if (!t) {
|
||||
try {
|
||||
const r = await fetch(`/podcasts/episodes/${id}/transcript/`);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
t = await r.json();
|
||||
t = await getJson(`/podcasts/episodes/${id}/transcript/`);
|
||||
transcripts.set(id, t);
|
||||
} catch (e) {
|
||||
body.textContent = '';
|
||||
|
|
@ -3301,12 +3344,8 @@
|
|||
function openEpisode(e, ctx) {
|
||||
closeNotes();
|
||||
ctx = ctx || {};
|
||||
notesSheet = el('div');
|
||||
notesSheet.id = 'nui-pod-player';
|
||||
notesSheet.className = 'nui-pod-notes-sheet nui-pod-episode';
|
||||
const panel = el('div', 'nui-pod-player-panel');
|
||||
const top = el('div', 'nui-pod-player-top');
|
||||
top.append(button('⌄', 'Schließen', closeNotes));
|
||||
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-episode', 'Schließen', closeNotes);
|
||||
notesSheet = made;
|
||||
const head = el('div', 'nui-pod-ephead');
|
||||
head.append(art(e.art, 'big', e.feedArt, e.feed));
|
||||
const info = el('div', 'nui-pod-main');
|
||||
|
|
@ -3339,8 +3378,6 @@
|
|||
const actions = el('div', 'nui-pod-epactions');
|
||||
const box = el('div', 'nui-pod-notesbox');
|
||||
panel.append(top, head, playBtn, actions, box);
|
||||
notesSheet.append(panel);
|
||||
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
|
||||
document.body.append(notesSheet);
|
||||
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
|
||||
episodeScreenParts();
|
||||
|
|
@ -3421,12 +3458,8 @@
|
|||
if (player.kind !== 'podcast') return;
|
||||
closePlayer();
|
||||
sheetKind = 'podcast';
|
||||
sheet = el('div');
|
||||
sheet.id = 'nui-pod-player';
|
||||
sheet.innerHTML = '<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue