Podcast-Radio: die neueste ungehörte Strom-Folge, ohne Spuren (eigene Radio-gehört-Liste, gemischt nach Podcasts, neue Folgen zuerst); Web-Player und Player-Sync; sw v153
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
21f5e33e57
commit
a64c801275
11 changed files with 497 additions and 25 deletions
|
|
@ -92,7 +92,7 @@ One controlled step out (Apple's episode search and fyyd; no key):
|
|||
|
||||
Show search stays on Apple (best coverage).
|
||||
|
||||
## Podcast-Radio (planned 2026-10-25)
|
||||
## Podcast-Radio (since 2026-10-25)
|
||||
|
||||
Listening without committing: one button plays the Strom like a station.
|
||||
|
||||
|
|
@ -100,6 +100,11 @@ Listening without committing: one button plays the Strom like a station.
|
|||
neither played, nor started (normally), nor done for the radio. So an episode that comes out
|
||||
while one plays is next (the one playing isn't interrupted); otherwise it goes back in time,
|
||||
without limit. Auf Abruf podcasts and audiobooks aren't in the Strom, so not in the radio.
|
||||
- **Mixed**: several episodes of one podcast in a row are mixed with the others waiting — the
|
||||
next is the newest of *another* podcast than the one before, if there is one (within the
|
||||
newest 200).
|
||||
- What comes next is asked anew at every end and skip (so the newest really comes next); the
|
||||
list fetched before is for "Danach: …" in the player and for going on offline.
|
||||
- **Leaves no trace**: no progress (no Verlauf, no "Angefangen", no played mark, not counted in
|
||||
`heard_365`), the Wiedergabeliste and Merkliste unchanged, nothing downloaded (it streams; a
|
||||
downloaded file plays if there is one), the Strom's marks unchanged.
|
||||
|
|
@ -108,14 +113,23 @@ Listening without committing: one button plays the Strom like a station.
|
|||
- **Committing**: in the player while the radio plays, + (Merkliste) and "Normal weiterhören"
|
||||
(first in the Wiedergabeliste at the current place; from then on it plays as usual, with
|
||||
progress). Playing anything normally switches the radio off (as an audiobook does).
|
||||
- **Server**: `PodcastRadioHeard(user, episode, at)`, kept apart from progress, pruned after 60
|
||||
days. `GET /podcasts/radio/?limit=5` → the next episodes by the rule (`{episodes: [...]}`, the
|
||||
rows of the episode lists); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices.
|
||||
- **Clients** (both at once): Android (`PodcastRadio.kt` beside `BookPlay.kt`: the playlist is
|
||||
[current, next]; RadioService's `save()` / `markPlayed()` / `followQueue()` leave radio items
|
||||
alone; reloads before each transition and every 10 minutes while playing — the server fetches
|
||||
feeds every 15 minutes) and the web player (`podcasts-web.js`, so the browser and the desktop
|
||||
app). Button "📻 Radio" at the top of the Strom; the mini player's ▶ goes on with the radio.
|
||||
- **Server** (`podcasts/radio.py`): `PodcastRadioHeard(user, episode, at)`, kept apart from
|
||||
progress and kept for good (pruned, an old episode would come back when the radio goes back
|
||||
that far). `GET /podcasts/radio/?after=<playing>&skip=<ids>&limit=5` → the next episodes by
|
||||
the rule (`{episodes: [...]}`, the rows of the episode lists; `after` isn't among them and its
|
||||
podcast not first; `skip`: ones the device is done with whose /heard/ may still be on its
|
||||
way); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. Filter `untouched`
|
||||
(episode lists): neither played nor listened into.
|
||||
- **Player sync**: a report carries `podcast_radio: true` while the radio plays
|
||||
(`PlayerState.podcast_radio`); taking it over ("Hier abspielen", a transfer) or adopting it goes
|
||||
on with the radio on that device.
|
||||
- **Clients**: the web player (`podcasts-web.js`: an entry with `radio: true`, the radio's own
|
||||
`podradio` in localStorage — next ones, its place, what it was done with lately; host
|
||||
`podcastRadio()`, `podcastRadioCommit()`, `podcastRadioLater()`; `playerState().radio =
|
||||
{next}`) and Android (`PodcastRadio.kt` beside `BookPlay.kt`; RadioService's `save()` /
|
||||
`markPlayed()` / `followQueue()` leave radio items alone). UI (native-podcast.js): 📻 at the top
|
||||
of the Strom, "Podcast-Radio · <podcast>" in the mini player and the player, there a box with
|
||||
"Danach: …", + Merkliste and "Normal weiterhören"; ⏭ only skips.
|
||||
- Later perhaps: a radio of one folder, a longest length.
|
||||
|
||||
## Moving over
|
||||
|
|
|
|||
33
podcasts/migrations/0025_podcast_radio.py
Normal file
33
podcasts/migrations/0025_podcast_radio.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 6.1 on 2026-10-25 12:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0024_feed_favorite'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='playerstate',
|
||||
name='podcast_radio',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PodcastRadioHeard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('at', models.DateTimeField(auto_now_add=True)),
|
||||
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='radio_heard_by', to='podcasts.podcastepisode')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_radio_heard', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'episode')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -290,6 +290,21 @@ class PodcastLater(models.Model):
|
|||
return f'{self.user} Merkliste: {self.episode}'
|
||||
|
||||
|
||||
class PodcastRadioHeard(models.Model):
|
||||
"""An episode the Podcast-Radio is done with (docs/podcasts.md): it ran to its end there or
|
||||
was skipped. Apart from the progress on purpose: it counts as heard nowhere else (no
|
||||
history, no played mark, not in heard_365). Kept, so the radio never comes back to it."""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_radio_heard')
|
||||
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='radio_heard_by')
|
||||
at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'episode')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user} Radio: {self.episode}'
|
||||
|
||||
|
||||
class PodcastSettings(models.Model):
|
||||
"""A user's podcast preferences. Where episodes go in the queue: when added
|
||||
by hand (also when played from outside the queue), and when a feed that
|
||||
|
|
@ -401,6 +416,9 @@ class PlayerState(models.Model):
|
|||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state')
|
||||
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
|
||||
episode = models.ForeignKey(PodcastEpisode, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
|
||||
# The episode plays in the Podcast-Radio (docs/podcasts.md): shown so elsewhere, and a
|
||||
# transfer goes on with the radio there rather than playing it the usual way.
|
||||
podcast_radio = models.BooleanField(default=False)
|
||||
station_url = models.CharField(max_length=1000, blank=True)
|
||||
station_name = models.CharField(max_length=300, blank=True)
|
||||
station_id = models.IntegerField(null=True, blank=True)
|
||||
|
|
|
|||
88
podcasts/radio.py
Normal file
88
podcasts/radio.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""The Podcast-Radio (docs/podcasts.md): the Strom played like a station, leaving no trace.
|
||||
|
||||
Which episode comes next: the newest (publication date) Strom episode that is neither played,
|
||||
nor listened into, nor done for the radio (PodcastRadioHeard) — so one that comes out while
|
||||
another plays is next, and otherwise the radio goes back in time. Several of one podcast in a
|
||||
row are mixed with the others waiting: the next is the newest of another podcast than the one
|
||||
before, if there is one.
|
||||
|
||||
GET /podcasts/radio/?after=<episode id>&skip=<ids>&limit=5 → {episodes: [...]} (episode-list rows)
|
||||
skip: ones the device is done with whose /heard/ may not have arrived yet
|
||||
POST /podcasts/radio/heard/ {"episode_id", "after"?} → {ok, episodes} (the next ones)
|
||||
"""
|
||||
import json
|
||||
|
||||
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 PodcastEpisode, PodcastRadioHeard
|
||||
from .views import _episode_list_data
|
||||
|
||||
LIMIT = 5
|
||||
MAX_LIMIT = 20
|
||||
MAX_SKIP = 50
|
||||
# How far down the newest the mixing looks for another podcast.
|
||||
WINDOW = 200
|
||||
|
||||
|
||||
def next_episodes(user, after=None, limit=LIMIT, skip=()):
|
||||
"""The radio's next episodes, in the order to play; `after`: the episode playing (or
|
||||
played last) — not itself again, and not its podcast next if another is waiting; `skip`:
|
||||
not these either."""
|
||||
candidates, _ = _episode_list_data(user, ['stream', 'untouched'], sort='new', limit=WINDOW, radio=True)
|
||||
skip = set(skip) | ({after} if after else set())
|
||||
candidates = [e for e in candidates if e['id'] not in skip]
|
||||
last_feed = None
|
||||
if after:
|
||||
last_feed = PodcastEpisode.objects.filter(pk=after, feed__user=user).values_list('feed_id', flat=True).first()
|
||||
out = []
|
||||
while candidates and len(out) < limit:
|
||||
pick = next((e for e in candidates if e['feed__id'] != last_feed), candidates[0])
|
||||
candidates.remove(pick)
|
||||
out.append(pick)
|
||||
last_feed = pick['feed__id']
|
||||
return out
|
||||
|
||||
|
||||
def _after(value):
|
||||
if value in (None, ''):
|
||||
return None
|
||||
after = int(value)
|
||||
if after <= 0:
|
||||
raise ValueError
|
||||
return after
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET'])
|
||||
def radio(request):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
after = _after(request.GET.get('after'))
|
||||
limit = min(max(int(request.GET.get('limit') or LIMIT), 1), MAX_LIMIT)
|
||||
skip = [int(i) for i in request.GET.get('skip', '').split(',') if i.strip()][:MAX_SKIP]
|
||||
except ValueError:
|
||||
return JsonResponse({'error': 'after, skip and limit must be numbers'}, status=400)
|
||||
return JsonResponse({'episodes': next_episodes(request.user, after, limit, skip)})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def radio_heard(request):
|
||||
"""The radio is done with an episode (ran to its end, or skipped): it never plays it again.
|
||||
Nothing else about the episode changes. Answers with the next ones (after it)."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
episode_id = int(body['episode_id'])
|
||||
after = _after(body.get('after'))
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return JsonResponse({'error': 'episode_id required'}, status=400)
|
||||
episode = PodcastEpisode.objects.filter(pk=episode_id, feed__user=request.user).first()
|
||||
if episode is None:
|
||||
return JsonResponse({'error': 'unknown episode'}, status=404)
|
||||
PodcastRadioHeard.objects.get_or_create(user=request.user, episode=episode)
|
||||
return JsonResponse({'ok': True, 'episodes': next_episodes(request.user, after or episode_id)})
|
||||
107
podcasts/test_radio.py
Normal file
107
podcasts/test_radio.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""The Podcast-Radio (docs/podcasts.md, podcasts/radio.py)."""
|
||||
import json
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from .models import EpisodeProgress, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastRadioHeard
|
||||
from .tests import _at, _progress
|
||||
|
||||
|
||||
class RadioTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha')
|
||||
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
|
||||
self.fc = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Abruf',
|
||||
new_episodes=PodcastFeed.NEW_IGNORE)
|
||||
self.book = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/h', title='Buch',
|
||||
kind=PodcastFeed.AUDIOBOOK)
|
||||
|
||||
def ep(self, feed, guid, day):
|
||||
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, pub_date=_at(1, day),
|
||||
audio_url=f'https://example.org/{guid}.mp3')
|
||||
|
||||
def radio(self, **params):
|
||||
r = self.client.get('/podcasts/radio/', params)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
return [e['title'] for e in r.json()['episodes']]
|
||||
|
||||
def heard(self, episode, **body):
|
||||
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': episode.id, **body}),
|
||||
content_type='application/json')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
return [e['title'] for e in r.json()['episodes']]
|
||||
|
||||
def test_newest_untouched_strom_episodes_first(self):
|
||||
self.ep(self.fa, 'a1', 1)
|
||||
self.ep(self.fb, 'b2', 2)
|
||||
played = self.ep(self.fa, 'a3', 3)
|
||||
started = self.ep(self.fb, 'b4', 4)
|
||||
self.ep(self.fc, 'c5', 5) # Auf Abruf: not in the Strom
|
||||
self.ep(self.book, 'h6', 6) # an audiobook's chapter
|
||||
_progress(self.user, played, _at(2, 1), played=True)
|
||||
_progress(self.user, started, _at(2, 1), position_seconds=30)
|
||||
self.assertEqual(self.radio(), ['b2', 'a1'])
|
||||
|
||||
def test_one_podcast_is_mixed_with_the_others(self):
|
||||
for d in (1, 2, 3):
|
||||
self.ep(self.fb, f'b{d}', d)
|
||||
for d in (7, 8, 9):
|
||||
self.ep(self.fa, f'a{d}', d)
|
||||
self.assertEqual(self.radio(limit=6), ['a9', 'b3', 'a8', 'b2', 'a7', 'b1'])
|
||||
# After an episode of Alpha: Beta first, and that episode not again.
|
||||
a9 = PodcastEpisode.objects.get(guid='a9')
|
||||
self.assertEqual(self.radio(after=a9.id, limit=3), ['b3', 'a8', 'b2'])
|
||||
# One the device is done with (its /heard/ still on the way) doesn't count for the mixing.
|
||||
a8 = PodcastEpisode.objects.get(guid='a8')
|
||||
self.assertEqual(self.radio(after=a9.id, skip=str(a8.id), limit=3), ['b3', 'a7', 'b2'])
|
||||
# Only one podcast left: its episodes in a row.
|
||||
PodcastEpisode.objects.filter(feed=self.fb).delete()
|
||||
self.assertEqual(self.radio(after=a9.id), ['a8', 'a7'])
|
||||
|
||||
def test_heard_leaves_no_trace_but_the_radio_moves_on(self):
|
||||
a1 = self.ep(self.fa, 'a1', 1)
|
||||
b2 = self.ep(self.fb, 'b2', 2)
|
||||
self.assertEqual(self.heard(b2), ['a1'])
|
||||
self.assertEqual(self.radio(), ['a1'])
|
||||
self.assertFalse(EpisodeProgress.objects.exists())
|
||||
self.assertFalse(PodcastQueue.objects.exists())
|
||||
# The Strom and the history don't know about it.
|
||||
r = self.client.get('/podcasts/episodes/', {'filter': 'history'}).json()
|
||||
self.assertEqual(r['total'], 0)
|
||||
r = self.client.get('/podcasts/episodes/', {'filter': 'stream,unplayed'}).json()
|
||||
self.assertEqual(r['total'], 2)
|
||||
self.heard(b2) # twice is fine
|
||||
self.assertEqual(PodcastRadioHeard.objects.count(), 1)
|
||||
self.heard(a1)
|
||||
self.assertEqual(self.radio(), [])
|
||||
|
||||
def test_others_episodes_and_bad_requests(self):
|
||||
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
|
||||
theirs = self.ep(fbob, 'x', 1)
|
||||
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': theirs.id}), content_type='application/json')
|
||||
self.assertEqual(r.status_code, 404)
|
||||
r = self.client.post('/podcasts/radio/heard/', '{}', content_type='application/json')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/radio/', {'after': 'x'}).status_code, 400)
|
||||
self.assertEqual(self.radio(), [])
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get('/podcasts/radio/').status_code, 401)
|
||||
|
||||
def test_player_state_says_radio(self):
|
||||
a1 = self.ep(self.fa, 'a1', 1)
|
||||
body = {'device_id': 'phone', 'device_name': 'Handy', 'kind': 'podcast', 'episode_id': a1.id,
|
||||
'playing': True, 'position_ms': 1000, 'start': True, 'podcast_radio': True}
|
||||
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json()['podcast_radio'])
|
||||
self.assertTrue(PlayerState.objects.get(user=self.user).podcast_radio)
|
||||
body['podcast_radio'] = False
|
||||
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
|
||||
self.assertFalse(r.json()['podcast_radio'])
|
||||
self.assertFalse(EpisodeProgress.objects.exists())
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django.urls import path
|
||||
from . import audiobooks, discover, folders, views
|
||||
from . import audiobooks, discover, folders, radio, views
|
||||
|
||||
urlpatterns = [
|
||||
path('search/', views.podcast_search, name='podcast_search'),
|
||||
|
|
@ -39,6 +39,8 @@ urlpatterns = [
|
|||
path('later/remove/', views.later_remove, name='podcast_later_remove'),
|
||||
path('lists/', views.lists_switch, name='podcast_lists_switch'),
|
||||
path('stream/seen/', views.stream_seen, name='podcast_stream_seen'),
|
||||
path('radio/', radio.radio, name='podcast_radio'),
|
||||
path('radio/heard/', radio.radio_heard, name='podcast_radio_heard'),
|
||||
# Stöbern (docs/podcasts.md, podcasts/discover.py)
|
||||
path('discover/search/', discover.search_view, name='podcast_discover_search'),
|
||||
path('discover/similar/<int:pk>/', discover.similar_view, name='podcast_discover_similar'),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from . import youtube as youtube_mod
|
|||
from . import folders as folders_mod
|
||||
from .models import (
|
||||
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
||||
PodcastQueue, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
||||
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
||||
)
|
||||
from .shownotes import extract_timestamps, sanitize_html
|
||||
|
||||
|
|
@ -1602,6 +1602,8 @@ EPISODE_FILTERS = {
|
|||
'all': Q(),
|
||||
'unplayed': Q(played=False),
|
||||
'in_progress': Q(position_seconds__gt=0, played=False),
|
||||
# Neither played nor listened into (the Podcast-Radio's, docs/podcasts.md).
|
||||
'untouched': Q(position_seconds=0, played=False),
|
||||
'played': Q(played=True),
|
||||
'favorites': Q(favorite=True),
|
||||
'history': Q(played=True) | Q(position_seconds__gt=0),
|
||||
|
|
@ -1638,7 +1640,7 @@ def _int_param(request, name, default):
|
|||
|
||||
|
||||
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
|
||||
sort=None, ids=None, notes=False, folder=None):
|
||||
sort=None, ids=None, notes=False, folder=None, radio=False):
|
||||
"""(episodes, total) for the episode list. Two queries, whatever the
|
||||
page size: the count and the page. The user's progress row comes in by
|
||||
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
|
||||
|
|
@ -1648,7 +1650,7 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
the newest first, in the history the last listened first. ids: only these
|
||||
episodes (the ones downloaded on a device). notes: `q` also matches the
|
||||
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
|
||||
in none)."""
|
||||
in none). radio: not the ones the Podcast-Radio is done with (PodcastRadioHeard)."""
|
||||
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
|
||||
names = [n for n in names if n and n != 'all']
|
||||
qs = (
|
||||
|
|
@ -1673,6 +1675,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
if (folder in (fs or []) if folder else not fs)])
|
||||
if ids is not None:
|
||||
qs = qs.filter(id__in=ids)
|
||||
if radio:
|
||||
qs = qs.exclude(Exists(PodcastRadioHeard.objects.filter(user=user, episode=OuterRef('pk'))))
|
||||
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')]
|
||||
|
|
@ -2695,6 +2699,7 @@ def _player_json(user, p, now=None):
|
|||
'seq': p.seq,
|
||||
'kind': p.kind,
|
||||
'episode': episode,
|
||||
'podcast_radio': bool(episode) and p.podcast_radio,
|
||||
'station': station,
|
||||
'position_ms': p.position_ms,
|
||||
'playing': p.playing,
|
||||
|
|
@ -2798,6 +2803,8 @@ def player_report(request):
|
|||
p,
|
||||
kind=kind,
|
||||
episode_id=episode_id,
|
||||
# The episode plays in the Podcast-Radio (docs/podcasts.md).
|
||||
podcast_radio=kind == PlayerState.PODCAST and body.get('podcast_radio') is True,
|
||||
station_url=station[0], station_name=station[1], station_id=station[2],
|
||||
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
|
||||
playing=playing and bool(kind),
|
||||
|
|
|
|||
|
|
@ -673,8 +673,17 @@
|
|||
store('book', b);
|
||||
}
|
||||
|
||||
// The Podcast-Radio (docs/podcasts.md): the Strom played like a station. An episode it plays
|
||||
// is an entry with radio: true; the radio keeps the next ones (from /podcasts/radio/), the place
|
||||
// of the one it plays and what it was done with lately — here only: nothing else learns of it
|
||||
// (no progress, no lists). Playing anything else switches it off.
|
||||
let podRadio = load('podradio', null); // {next: [entry, …], pos: {id, seconds}, done: [id, …]}
|
||||
if (!podRadio || !Array.isArray(podRadio.next)) podRadio = null;
|
||||
let radioFetchedAt = 0;
|
||||
|
||||
const curId = () => (cur ? cur.episode__id : null);
|
||||
const curFeed = () => (cur ? cur.episode__feed__id : null);
|
||||
const onRadio = () => !!(cur && cur.radio && podRadio);
|
||||
|
||||
function durMs() {
|
||||
if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000;
|
||||
|
|
@ -689,6 +698,10 @@
|
|||
|
||||
// Where this episode picks up: its place (the queue knows it best), past the podcast's intro.
|
||||
function resumeMs() {
|
||||
if (onRadio()) {
|
||||
const at = podRadio.pos && podRadio.pos.id === cur.episode__id ? podRadio.pos.seconds * 1000 : 0;
|
||||
return Math.max(at > 5000 ? at : 0, introMs(curFeed()));
|
||||
}
|
||||
const q = cur ? entry(cur.episode__id) : null;
|
||||
const resume = ((q || cur || {}).position_seconds || 0) * 1000;
|
||||
return Math.max(resume > 5000 ? resume : 0, introMs(curFeed()));
|
||||
|
|
@ -701,6 +714,7 @@
|
|||
}
|
||||
|
||||
function nextEntry() {
|
||||
if (onRadio()) return podRadio.next[0] || null; // the radio goes on with what the server said
|
||||
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);
|
||||
|
|
@ -711,12 +725,18 @@
|
|||
|
||||
function saveNow(id, send) {
|
||||
lastSaved = Date.now();
|
||||
if (onRadio() && id === curId()) { // the radio's place, for the radio only
|
||||
podRadio.pos = {id, seconds: Math.floor(posMs() / 1000)};
|
||||
store('podradio', podRadio);
|
||||
return;
|
||||
}
|
||||
saveProgress(id, Math.floor(posMs() / 1000));
|
||||
if (send) flush();
|
||||
}
|
||||
|
||||
// An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
|
||||
function finished(id) {
|
||||
if (onRadio() && id === curId()) { radioDone(id); return sleep.episodeFinished(); }
|
||||
const at = bookIndex(id);
|
||||
if (at >= 0) { book.chapters[at].played = true; store('book', book); }
|
||||
markPlayedAll([id], true, prefs.remove_played);
|
||||
|
|
@ -830,13 +850,25 @@
|
|||
const prev = curId();
|
||||
let stop = false;
|
||||
if (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left
|
||||
const fromRadio = onRadio();
|
||||
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
|
||||
if (auto || almostEnded()) stop = finished(prev);
|
||||
// The radio's episode: ended or skipped, the radio is done with it; else it keeps its place.
|
||||
if (fromRadio) {
|
||||
if (auto || skipped || almostEnded()) radioDone(prev);
|
||||
if (auto || almostEnded()) stop = sleep.episodeFinished();
|
||||
else if (!skipped) saveNow(prev, false);
|
||||
} else if (auto || almostEnded()) stop = finished(prev);
|
||||
else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
|
||||
else saveNow(prev, true);
|
||||
}
|
||||
unload();
|
||||
if (podRadio && !e.radio) setPodRadio(null); // something else plays: the radio is off
|
||||
cur = Object.assign({}, e);
|
||||
if (e.radio) {
|
||||
podRadio.next = podRadio.next.filter(x => x.episode__id !== e.episode__id);
|
||||
store('podradio', podRadio);
|
||||
radioFetch();
|
||||
}
|
||||
startAt = null;
|
||||
pausedAt = 0;
|
||||
buffering = false;
|
||||
|
|
@ -917,6 +949,7 @@
|
|||
|
||||
// To the next episode: the one left is skipped (see goTo).
|
||||
function next() {
|
||||
if (onRadio()) { radioAdvance(curId(), false); return; }
|
||||
const n = nextEntry();
|
||||
if (n) goTo(n, false, wantPlay, true);
|
||||
}
|
||||
|
|
@ -942,7 +975,7 @@
|
|||
}
|
||||
|
||||
function playEpisode(id) {
|
||||
if (curId() === id) { resume(); return; }
|
||||
if (curId() === id) { radioCommit(); resume(); return; } // played by hand: no longer the radio's
|
||||
const e = entry(id);
|
||||
if (e) goTo(e, false, true);
|
||||
}
|
||||
|
|
@ -965,6 +998,7 @@
|
|||
const dur = durMs();
|
||||
if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
|
||||
outroDone = id;
|
||||
if (onRadio()) { radioAdvance(id, true); return; }
|
||||
const n = nextEntry(); // before finished() takes this one out of the queue
|
||||
if (finished(id) || !n) pause();
|
||||
else goTo(n, false, true);
|
||||
|
|
@ -973,6 +1007,7 @@
|
|||
function onEnded() {
|
||||
const id = curId();
|
||||
if (id == null) return;
|
||||
if (onRadio()) { if (id !== outroDone) radioAdvance(id, true); return; }
|
||||
const n = nextEntry();
|
||||
if (n) { goTo(n, true, true); return; }
|
||||
if (id !== outroDone) finished(id);
|
||||
|
|
@ -1058,6 +1093,7 @@
|
|||
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
|
||||
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
|
||||
if (playing) skipOutro(id);
|
||||
if (playing && onRadio() && Date.now() - radioFetchedAt > (durMs() - posMs() < 90000 ? 120000 : 600000)) radioFetch();
|
||||
}
|
||||
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient).
|
||||
sleep.tick(playing || radioPlaying() || ambientPlaying());
|
||||
|
|
@ -1067,6 +1103,121 @@
|
|||
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
|
||||
}, 1000);
|
||||
|
||||
// ---- the Podcast-Radio (docs/podcasts.md) -----------------------------------------------------
|
||||
|
||||
function setPodRadio(r) {
|
||||
podRadio = r;
|
||||
store('podradio', r);
|
||||
}
|
||||
|
||||
const radioEntry = ep => Object.assign({}, asQueueEntry(ep), {radio: true, position_seconds: 0});
|
||||
|
||||
// The next ones from the server, after the episode playing (not it, not its podcast next).
|
||||
function radioFetch() {
|
||||
radioFetchedAt = Date.now();
|
||||
const after = onRadio() ? curId() : null;
|
||||
const q = new URLSearchParams({limit: '5'});
|
||||
if (after) q.set('after', String(after));
|
||||
// Done here lately: maybe not on the server yet (the mixing must not count them).
|
||||
if (podRadio && podRadio.done && podRadio.done.length) q.set('skip', podRadio.done.slice(0, 20).join(','));
|
||||
return fetch('/podcasts/radio/?' + q, {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
.then(d => {
|
||||
if (!d || !podRadio) return null; // couldn't ask (offline): the caller keeps what it knew
|
||||
const done = podRadio.done || [];
|
||||
podRadio.next = (d.episodes || []).filter(e => e.id !== curId() && done.indexOf(e.id) < 0).map(radioEntry);
|
||||
store('podradio', podRadio);
|
||||
report();
|
||||
return podRadio.next;
|
||||
});
|
||||
}
|
||||
|
||||
// Ended or skipped: the radio is done with it (kept here too, until the server has it).
|
||||
function radioDone(id) {
|
||||
if (!podRadio) return;
|
||||
podRadio.done = [id].concat((podRadio.done || []).filter(x => x !== id)).slice(0, 50);
|
||||
podRadio.next = podRadio.next.filter(x => x.episode__id !== id);
|
||||
if (podRadio.pos && podRadio.pos.id === id) podRadio.pos = null;
|
||||
store('podradio', podRadio);
|
||||
queueOp('/podcasts/radio/heard/', {episode_id: id}, 'radio-heard:' + id);
|
||||
flush();
|
||||
}
|
||||
|
||||
// The radio moves on from `id` (ended: auto, or skipped): done with it, and what comes next is
|
||||
// asked anew, so an episode that came out a minute ago goes first; offline, what it knew.
|
||||
function radioAdvance(id, auto) {
|
||||
const known = podRadio.next.filter(x => x.episode__id !== id)[0] || null;
|
||||
const play = auto || wantPlay;
|
||||
radioDone(id);
|
||||
outroDone = id; // goTo doesn't count it again
|
||||
if (auto && sleep.episodeFinished()) { pause(); return; }
|
||||
radioFetch().then(list => {
|
||||
if (curId() !== id || !onRadio()) return; // something else plays meanwhile
|
||||
const n = list ? list[0] : known;
|
||||
if (n) { goTo(n, false, play); return; }
|
||||
pause();
|
||||
toast('Podcast-Radio: gerade nichts Ungehörtes im Strom');
|
||||
});
|
||||
}
|
||||
|
||||
// 📻: the radio plays (goes on with its episode, or starts with the newest).
|
||||
function radioStart() {
|
||||
// Its episode, unless the radio is done with it (it ran out of episodes there).
|
||||
if (onRadio() && (podRadio.done || []).indexOf(curId()) < 0) { resume(); return; }
|
||||
setPodRadio({next: [], pos: null, done: (podRadio && podRadio.done) || []});
|
||||
radioFetch().then(list => {
|
||||
if (!podRadio) return;
|
||||
if (!list) { setPodRadio(null); toast('Keine Verbindung zum Server'); return; }
|
||||
if (!list.length) { setPodRadio(null); toast('Im Strom ist gerade nichts Ungehörtes'); return; }
|
||||
goTo(list[0], false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// "Normal weiterhören": the episode leaves the radio, first in the Wiedergabeliste at its place;
|
||||
// from now on it plays as usual (with progress).
|
||||
function radioCommit() {
|
||||
if (!onRadio()) return;
|
||||
const id = curId();
|
||||
const e = Object.assign({}, cur, {position_seconds: Math.floor(posMs() / 1000)});
|
||||
delete e.radio;
|
||||
setPodRadio(null);
|
||||
cur = e;
|
||||
storePlayer();
|
||||
enqueue([e], 'front', null);
|
||||
saveProgress(id, e.position_seconds);
|
||||
flush();
|
||||
setSession();
|
||||
report();
|
||||
syncSoon();
|
||||
}
|
||||
|
||||
// The radio's episode from another device (a transfer, or adopting it): the radio here.
|
||||
function loadRadioAt(ep, at) {
|
||||
at = Math.max(0, Math.round(at || 0));
|
||||
if (!podRadio) setPodRadio({next: [], pos: null, done: []});
|
||||
if (curId() !== ep.id || !cur.radio) {
|
||||
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
|
||||
unload();
|
||||
cur = radioEntry(ep);
|
||||
wantPlay = false;
|
||||
buffering = false;
|
||||
error = null;
|
||||
lastDur = 0;
|
||||
storePlayer();
|
||||
} else if (loaded && audio.readyState > 0) {
|
||||
try { audio.currentTime = at / 1000; } catch (e) {}
|
||||
}
|
||||
startAt = loaded ? null : at;
|
||||
mustReach = null;
|
||||
pausedAt = 0;
|
||||
lastPos = at;
|
||||
podRadio.pos = {id: ep.id, seconds: Math.floor(at / 1000)};
|
||||
store('podradio', podRadio);
|
||||
radioFetch();
|
||||
report();
|
||||
}
|
||||
|
||||
// ---- skip silence ----------------------------------------------------------------------
|
||||
// A browser can't listen to most podcast servers' audio (no CORS), so the server works out
|
||||
// once per episode which parts of its pauses go (podcasts/silence.py, the same rules as the
|
||||
|
|
@ -1448,7 +1599,7 @@
|
|||
}
|
||||
if (cur) {
|
||||
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
|
||||
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
|
||||
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz, podcast_radio: onRadio()};
|
||||
}
|
||||
return {kind: '', position_ms: 0, playing: false};
|
||||
}
|
||||
|
|
@ -1614,6 +1765,7 @@
|
|||
} else {
|
||||
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
|
||||
unload();
|
||||
if (podRadio) setPodRadio(null);
|
||||
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
|
||||
startAt = at;
|
||||
wantPlay = false;
|
||||
|
|
@ -1635,7 +1787,7 @@
|
|||
// (another device played on): never while it plays here, never over a place
|
||||
// of ours that isn't saved yet, not for a few seconds' difference.
|
||||
function movePausedTo(id, at) {
|
||||
if (curId() !== id || wantPlay) return false;
|
||||
if (curId() !== id || wantPlay || onRadio()) return false;
|
||||
if (outbox.some(op => op.key === 'progress:' + id)) return false;
|
||||
if (Math.abs(at - posMs()) <= 5000) return false;
|
||||
loadAt({id}, at);
|
||||
|
|
@ -1657,6 +1809,9 @@
|
|||
st = st || sync.state;
|
||||
if (st && st.kind === 'radio' && st.station) {
|
||||
playStation(st.station.url, st.station.name || '', st.station.id || null);
|
||||
} else if (st && st.kind === 'podcast' && st.episode && st.podcast_radio) {
|
||||
loadRadioAt(st.episode, placeOf(st));
|
||||
resume();
|
||||
} else if (st && st.kind === 'podcast' && st.episode) {
|
||||
loadAt(st.episode, placeOf(st));
|
||||
resume();
|
||||
|
|
@ -1668,7 +1823,9 @@
|
|||
// The active device is gone and we play nothing: its item becomes ours, paused.
|
||||
function adopt(st) {
|
||||
if (wantPlay || radioPlaying()) return;
|
||||
if (st.kind === 'podcast' && st.episode) {
|
||||
if (st.kind === 'podcast' && st.episode && st.podcast_radio) {
|
||||
loadRadioAt(st.episode, st.position_ms);
|
||||
} else if (st.kind === 'podcast' && st.episode) {
|
||||
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
|
||||
else loadAt(st.episode, st.position_ms);
|
||||
} else if (st.kind === 'radio' && st.station) {
|
||||
|
|
@ -1792,6 +1949,10 @@
|
|||
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};
|
||||
if (onRadio()) {
|
||||
const n = podRadio.next[0];
|
||||
o.radio = {next: n ? {title: n.episode__title || '', feed: n.episode__feed__title || ''} : null};
|
||||
}
|
||||
o.playing = wantPlay;
|
||||
o.buffering = buffering && wantPlay;
|
||||
if (error) o.error = error;
|
||||
|
|
@ -1819,7 +1980,7 @@
|
|||
try {
|
||||
ms.metadata = new MediaMetadata({
|
||||
title: plain(cur.episode__title),
|
||||
artist: plain(cur.episode__feed__title),
|
||||
artist: (onRadio() ? 'Podcast-Radio · ' : '') + plain(cur.episode__feed__title),
|
||||
album: plain(cur.episode__feed__title),
|
||||
artwork: art ? [{src: art}] : [],
|
||||
});
|
||||
|
|
@ -1939,6 +2100,11 @@
|
|||
flush();
|
||||
},
|
||||
podcastNext: () => next(),
|
||||
// The Podcast-Radio (docs/podcasts.md): play it, or take its episode out of it.
|
||||
podcastRadio: () => radioStart(),
|
||||
podcastRadioCommit: () => radioCommit(),
|
||||
// + while the radio plays it: onto the Merkliste (the radio plays on).
|
||||
podcastRadioLater: sent(() => { if (onRadio()) { const e = Object.assign({}, cur); delete e.radio; laterAdd([e]); } }),
|
||||
podcastToggle: () => (wantPlay ? pause() : resume()),
|
||||
podcastPause: () => { if (wantPlay) pause(); },
|
||||
podcastSeek: ms => seek(Number(ms)),
|
||||
|
|
@ -2076,12 +2242,15 @@
|
|||
const was = load('player', null);
|
||||
if (was && was.entry && was.entry.episode__id != null) {
|
||||
const at = bookIndex(was.entry.episode__id);
|
||||
cur = entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
|
||||
cur = was.entry.radio && podRadio ? was.entry
|
||||
: entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
|
||||
if (cur.radio && !podRadio) delete cur.radio;
|
||||
lastPos = resumeMs();
|
||||
}
|
||||
|
||||
// Keep the place when the page goes away; send what's pending when the connection is back.
|
||||
const leaving = () => {
|
||||
if (onRadio()) { if (loaded && audio.readyState > 0) saveNow(cur.episode__id, false); return; }
|
||||
if (cur && loaded && audio.readyState > 0) {
|
||||
saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
|
||||
if (navigator.sendBeacon) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v152';
|
||||
const CACHE = 'diora-v153';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 6cfec49 2026-10-25: web-native/static/native-podcast.js
|
||||
// Copied from diora-android 7f37d4e 2026-10-25: 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.
|
||||
|
||||
|
|
@ -384,8 +384,10 @@
|
|||
};
|
||||
}
|
||||
|
||||
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book).
|
||||
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book);
|
||||
// "Podcast-Radio · <podcast>" while the radio plays (playerState().radio, diora-web docs/podcasts.md).
|
||||
function bookLine(p) {
|
||||
if (p && p.radio) return 'Podcast-Radio · ' + plain(p.feed || '');
|
||||
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) : '');
|
||||
|
|
@ -3365,6 +3367,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
const head = el('div', 'nui-pod-head');
|
||||
head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`),
|
||||
listTools([
|
||||
view === 'stream' && N.podcastRadio && button('📻', 'Podcast-Radio: immer die neueste ungehörte Folge – ohne Spuren in Listen und Verlauf',
|
||||
() => N.podcastRadio(), 'nui-pod-radiobtn'),
|
||||
view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'),
|
||||
button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))),
|
||||
button('☐', 'Mehrere auswählen', () => startSelecting()),
|
||||
|
|
@ -4715,7 +4719,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
closePlayer();
|
||||
window.__dioraPodcastOpenFeed(id);
|
||||
});
|
||||
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox);
|
||||
// The Podcast-Radio: what comes next; + (Merkliste) and "Normal weiterhören" (diora-web docs/podcasts.md).
|
||||
const radioBox = el('div', 'nui-pod-radiobox');
|
||||
radioBox.hidden = true;
|
||||
const radioActs = el('div', 'nui-pod-radioacts');
|
||||
radioActs.append(
|
||||
button('+ Merkliste', 'Auf die Merkliste (das Radio spielt weiter)', () => { N.podcastRadioLater(); toast('Auf der Merkliste'); }, 'nui-pod-radiolater'),
|
||||
button('Normal weiterhören', 'Aus dem Radio nehmen: zuerst in die Wiedergabeliste, ab hier mit Verlauf',
|
||||
() => { N.podcastRadioCommit(); toast('Läuft jetzt normal – zuerst in der Wiedergabeliste'); }, 'nui-pod-radiocommit'),
|
||||
);
|
||||
radioBox.append(el('div', 'nui-pod-radionext'), radioActs);
|
||||
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox, radioBox);
|
||||
const range = el('input', 'nui-pod-seek');
|
||||
range.type = 'range';
|
||||
range.min = 0;
|
||||
|
|
@ -4873,6 +4887,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
markChapter(sheet, (player.pos || 0) / 1000);
|
||||
sheet.querySelector('.nui-pod-player-title').textContent = player.title || '';
|
||||
sheet.querySelector('.nui-pod-player-feed').textContent = bookLine(player) || player.feed || '';
|
||||
const radioBox = sheet.querySelector('.nui-pod-radiobox');
|
||||
if (radioBox) {
|
||||
radioBox.hidden = !player.radio || !N.podcastRadioCommit;
|
||||
const n = player.radio && player.radio.next;
|
||||
radioBox.querySelector('.nui-pod-radionext').textContent = n ? `Danach: ${plain(n.feed)} – ${plain(n.title)}` : '';
|
||||
}
|
||||
const nx = sheet.querySelector('.nui-pod-next');
|
||||
if (nx) {
|
||||
if (!nx.dataset.title) nx.dataset.title = nx.title;
|
||||
nx.title = player.radio ? 'Überspringen – das Radio spielt die nächste' : nx.dataset.title;
|
||||
}
|
||||
const range = sheet.querySelector('.nui-pod-seek');
|
||||
const dur = Math.round((player.dur || 0) / 1000);
|
||||
const pos = Math.round((player.pos || 0) / 1000);
|
||||
|
|
@ -5107,6 +5132,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
function finishAndNext() {
|
||||
const id = currentId();
|
||||
if (!id) return;
|
||||
if (player.radio) { N.podcastNext(); return; } // the radio is done with it, nothing else
|
||||
const ids = queue().map(q => q.episode__id);
|
||||
const at = ids.indexOf(id);
|
||||
const last = at < 0 ? !ids.length : at === ids.length - 1;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 1c1ec4e 2026-10-19: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android 7f37d4e 2026-10-25: 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. */
|
||||
|
||||
|
|
@ -803,6 +803,14 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
|
|||
/* the player's chapters, right under the title */
|
||||
.nui-pod-chapbox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 2px 4px 2px 10px; }
|
||||
.nui-pod-chapbox[hidden] { display: none; }
|
||||
/* the Podcast-Radio (diora-web docs/podcasts.md): what comes next, + and "Normal weiterhören" */
|
||||
.nui-pod-radiobox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px 6px 10px; }
|
||||
.nui-pod-radiobox[hidden] { display: none; }
|
||||
.nui-pod-radionext { font-size: 12px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nui-pod-radionext:empty { display: none; }
|
||||
.nui-pod-radioacts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.nui-pod-radioacts .nui-icon { width: auto !important; padding: 0 12px !important; font-size: 13px !important; border: 1px solid var(--border); border-radius: 16px; }
|
||||
body.eink .nui-pod-radiobox, body.eink .nui-pod-radioacts .nui-icon { border-color: #000; }
|
||||
.nui-pod-chaphead { display: flex; align-items: center; gap: 2px; min-height: 40px; }
|
||||
.nui-pod-chapcount { flex: 1; min-width: 0; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
|
||||
.nui-pod-chapbtn { min-width: 40px !important; min-height: 40px !important; font-size: 16px !important; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue