Compare commits

..

No commits in common. "262dd0d207a78322761c098a3ec340299f33af42" and "713b25f36a09b18998c74c89d0e06f6337e8169d" have entirely different histories.

10 changed files with 30 additions and 1559 deletions

View file

@ -1,51 +0,0 @@
# Generated by Django 6.1 on 2026-10-02 12:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0011_settings_import_downloads'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PlayerState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(blank=True, choices=[('', 'Nichts'), ('podcast', 'Podcast'), ('radio', 'Radio')], default='', max_length=10)),
('station_url', models.CharField(blank=True, max_length=1000)),
('station_name', models.CharField(blank=True, max_length=300)),
('station_id', models.IntegerField(blank=True, null=True)),
('position_ms', models.PositiveBigIntegerField(default=0)),
('playing', models.BooleanField(default=False)),
('speed', models.FloatField(default=1.0)),
('device_id', models.CharField(blank=True, max_length=64)),
('device_name', models.CharField(blank=True, max_length=100)),
('updated_at', models.DateTimeField(blank=True, null=True)),
('seq', models.BigIntegerField(default=0)),
('command', models.JSONField(blank=True, null=True)),
('online_devices', models.JSONField(blank=True, default=list)),
('episode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='podcasts.podcastepisode')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='player_state', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PlayerDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('device_id', models.CharField(max_length=64)),
('device_name', models.CharField(blank=True, max_length=100)),
('kind', models.CharField(blank=True, choices=[('', 'Unbekannt'), ('app', 'App'), ('web', 'Browser')], default='', max_length=10)),
('last_seen', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_devices', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'device_id')},
},
),
]

View file

@ -254,65 +254,3 @@ class PodcastSettings(models.Model):
def __str__(self):
return f'Podcast settings of {self.user}'
class PlayerState(models.Model):
"""What a user's player plays, shared by all their devices (player sync,
like Spotify Connect): the device that starts playing becomes the active
one and stays it, also while paused, as long as it is online; the others
stop and show it as playing (or paused) there, with remote controls.
position_ms is the place at updated_at, the time of the last accepted
report; while playing, clients extrapolate from there. seq counts every
change (report, command, command done, a device coming or going) so the
stream can tell what's new.
"""
PODCAST = 'podcast'
RADIO = 'radio'
KINDS = [('', 'Nichts'), (PODCAST, 'Podcast'), (RADIO, 'Radio')]
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='+')
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)
position_ms = models.PositiveBigIntegerField(default=0)
playing = models.BooleanField(default=False)
speed = models.FloatField(default=1.0)
device_id = models.CharField(max_length=64, blank=True)
device_name = models.CharField(max_length=100, blank=True)
updated_at = models.DateTimeField(null=True, blank=True)
seq = models.BigIntegerField(default=0)
# A remote command for the active device, until it reports it done:
# {id, action, value, from_device_id, from_device_name, at}.
command = models.JSONField(null=True, blank=True)
# The ids of the devices online at the last change, sorted: when the set
# differs, seq goes up so every device sees others come and go.
online_devices = models.JSONField(default=list, blank=True)
@classmethod
def for_user(cls, user):
obj, _ = cls.objects.get_or_create(user=user)
return obj
def __str__(self):
return f'Player of {self.user}'
class PlayerDevice(models.Model):
"""A device that has the player sync open (its stream, its reports): online
while last seen at most PLAYER_ONLINE_SECONDS ago (podcasts/views.py)."""
KINDS = [('', 'Unbekannt'), ('app', 'App'), ('web', 'Browser')]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='player_devices')
device_id = models.CharField(max_length=64)
device_name = models.CharField(max_length=100, blank=True)
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
last_seen = models.DateTimeField()
class Meta:
unique_together = ('user', 'device_id')
def __str__(self):
return f'{self.device_name or self.device_id} of {self.user}'

View file

@ -1,365 +0,0 @@
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone as dj_timezone
from accounts.models import ApiToken
from . import views
from .models import PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue
STATION = {'url': 'https://example.org/stream', 'name': 'Example FM', 'id': 7}
class PlayerSyncTests(TestCase):
"""The shared player (podcasts/views.py, "Player sync"): reports, commands,
presence and the stream."""
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/feed', title='Feed')
self.ep = PodcastEpisode.objects.create(
feed=self.feed, guid='e1', title='E1', audio_url='https://example.org/e1.mp3', duration_seconds=3600,
pub_date=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.ep2 = PodcastEpisode.objects.create(
feed=self.feed, guid='e2', title='E2', audio_url='https://example.org/e2.mp3',
)
def post(self, path, body):
return self.client.post(f'/podcasts/player/{path}/', json.dumps(body), content_type='application/json')
def report(self, device, playing, kind='podcast', episode=None, pos=0, **extra):
body = {'device_id': device, 'device_name': device.upper(), 'kind': kind, 'position_ms': pos,
'playing': playing, **extra}
if kind == 'podcast':
body['episode_id'] = (episode or self.ep).id
elif kind == 'radio':
body['station'] = STATION
return self.post('report', body)
def command(self, device, action, value=None, to=None):
body = {'device_id': device, 'device_name': device.upper(), 'action': action}
if value is not None:
body['value'] = value
if to is not None:
body['to_device_id'] = to
return self.post('command', body)
def state(self):
return PlayerState.objects.get(user=self.user)
def go_offline(self, device, seconds=60):
PlayerDevice.objects.filter(user=self.user, device_id=device).update(
last_seen=dj_timezone.now() - timedelta(seconds=seconds))
def test_logged_out(self):
self.client.logout()
for r in (self.client.get('/podcasts/player/'), self.client.get('/podcasts/player/stream/'),
self.report('a', True), self.command('a', 'pause')):
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json(), {'error': 'authentication required'})
def test_bearer_token(self):
self.client.logout()
token = ApiToken.objects.create(user=self.user)
r = self.client.get('/podcasts/player/', HTTP_AUTHORIZATION=f'Bearer {token.token}')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['kind'], '')
def test_empty_state(self):
d = self.client.get('/podcasts/player/').json()
self.assertEqual(d['seq'], 0)
self.assertEqual(d['kind'], '')
self.assertIsNone(d['episode'])
self.assertIsNone(d['station'])
self.assertIs(d['playing'], False)
self.assertIsNone(d['command'])
self.assertEqual(d['devices'], [])
self.assertIs(d['active_online'], False)
self.assertRegex(d['server_time'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
def test_play_makes_the_device_active(self):
PodcastQueue.objects.create(user=self.user, episode=self.ep, position=0)
d = self.report('a', True, pos=12345, speed=1.5).json()
self.assertEqual(d['seq'], 2) # the report, and device a coming online
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
'in_queue'})
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
self.assertRegex(d['updated_at'], r'Z$')
self.assertEqual(d['devices'], [{'id': 'a', 'name': 'A', 'online': True, 'active': True}])
self.assertIs(d['active_online'], True)
self.assertEqual(self.client.get('/podcasts/player/').json()['seq'], 2)
def test_play_on_another_device_takes_over(self):
self.report('a', True)
self.command('b', 'pause')
d = self.report('b', True, kind='radio', start=True).json()
self.assertEqual((d['device_id'], d['kind'], d['playing']), ('b', 'radio', True))
self.assertEqual(d['station'], STATION)
self.assertIsNone(d['episode'])
self.assertIsNone(d['command']) # it was for device a
self.assertEqual([(x['id'], x['active']) for x in d['devices']], [('a', False), ('b', True)])
def test_late_periodic_report_of_the_old_device_is_ignored(self):
# b takes over; a's 10 s "playing" report, already on its way, comes after.
self.report('a', True, pos=1000)
self.report('b', True, pos=1500, start=True)
seq = self.state().seq
d = self.report('a', True, pos=11000).json()
self.assertEqual((d['seq'], d['device_id'], d['position_ms'], d['playing']), (seq, 'b', 1500, True))
# The user starts playback on a again: then a is the active device.
d = self.report('a', True, pos=12000, start=True).json()
self.assertEqual((d['device_id'], d['position_ms']), ('a', 12000))
def test_playing_without_start_wins_only_without_an_online_active_device(self):
self.report('a', False, pos=1000) # a is the active device, paused
self.assertEqual(self.report('b', True, pos=7).json()['device_id'], 'a')
self.go_offline('a')
self.assertEqual(self.report('b', True, pos=7).json()['device_id'], 'b')
def test_seq_only_goes_up(self):
seqs = [self.client.get('/podcasts/player/').json()['seq']]
seqs.append(self.report('a', True, start=True).json()['seq'])
seqs.append(self.report('b', True).json()['seq']) # ignored, b came online
seqs.append(self.command('b', 'pause').json()['seq'])
seqs.append(self.report('b', False).json()['seq']) # ignored
seqs.append(self.report('a', False).json()['seq'])
self.go_offline('a')
views._check_online(self.user)
seqs.append(self.client.get('/podcasts/player/').json()['seq'])
self.assertEqual(seqs, sorted(seqs))
self.assertEqual(seqs, [0, 2, 3, 4, 4, 5, 6])
def test_active_device_updates_and_stays_active_while_paused(self):
self.report('a', True, pos=1000)
d = self.report('a', False, episode=self.ep2, pos=5000).json()
self.assertEqual((d['device_id'], d['playing'], d['episode']['id'], d['position_ms']), ('a', False, self.ep2.id, 5000))
# Paused, but online: device b's paused choice doesn't count.
seq = d['seq']
d = self.report('b', False, episode=self.ep, pos=99).json()
self.assertEqual((d['seq'], d['device_id'], d['episode']['id'], d['position_ms']), (seq + 1, 'a', self.ep2.id, 5000))
# (seq + 1: b came online; the report itself changed nothing.)
self.assertIs(d['active_online'], True)
self.assertEqual(self.state().device_id, 'a')
def test_paused_report_ignored_while_the_active_device_plays(self):
self.report('a', True, pos=1000)
self.report('b', True, start=True)
self.report('a', True, pos=2000, start=True) # a plays again: a is active
seq = self.state().seq
d = self.report('b', False, kind='').json()
self.assertEqual((d['seq'], d['device_id'], d['playing'], d['kind']), (seq, 'a', True, 'podcast'))
def test_active_device_offline_newest_report_wins(self):
self.report('a', True, pos=1000)
self.report('b', False, pos=1) # b online too
self.go_offline('a')
d = self.report('b', False, kind='radio').json()
self.assertEqual((d['device_id'], d['kind'], d['playing']), ('b', 'radio', False))
self.assertIs(d['active_online'], True)
self.assertEqual([(x['id'], x['online']) for x in d['devices']], [('b', True), ('a', False)])
def test_online_is_presence_not_the_last_report(self):
# Rule 4: playing with the last report a minute old, but the device is
# still online (its stream pings): still active.
self.report('a', True, pos=1000)
PlayerState.objects.filter(user=self.user).update(updated_at=dj_timezone.now() - timedelta(seconds=60))
d = self.report('b', False, kind='radio').json()
self.assertEqual(d['device_id'], 'a')
# 45 s without a sign of life: gone.
self.go_offline('a', 44)
self.assertEqual(self.report('b', False, kind='radio').json()['device_id'], 'a')
self.go_offline('a', 46)
self.assertEqual(self.report('b', False, kind='radio').json()['device_id'], 'b')
def test_no_active_device_newest_report_wins(self):
d = self.report('a', False, pos=4000).json()
self.assertEqual((d['device_id'], d['position_ms'], d['playing']), ('a', 4000, False))
def test_empty_kind_clears_the_item(self):
self.report('a', True, kind='radio')
d = self.report('a', True, kind='').json()
self.assertEqual((d['kind'], d['station'], d['episode'], d['playing']), ('', None, None, False))
def test_invalid_reports(self):
other = User.objects.create_user(username='bob', password='pw12345678')
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/feed', title='Bob')
theirs = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3')
r = self.report('a', True, episode=theirs)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()['error'], 'unknown episode')
for body in ({'kind': 'podcast', 'playing': True}, # no device
{'device_id': 'a', 'kind': 'tv', 'playing': True},
{'device_id': 'a', 'kind': 'podcast', 'playing': True}, # no episode
{'device_id': 'a', 'kind': 'radio', 'playing': True, 'station': {'name': 'x'}},
{'device_id': 'a', 'kind': '', 'playing': 'yes'},
{'device_id': 'a', 'kind': '', 'playing': True, 'position_ms': 'x'}):
with self.subTest(body=body):
self.assertEqual(self.post('report', body).status_code, 400)
self.assertFalse(PlayerState.objects.filter(user=self.user, seq__gt=0).exists())
def test_command_is_stored_and_cleared_by_done_command(self):
self.report('a', True, pos=1000)
r = self.command('b', 'seek', 61000)
self.assertEqual(r.status_code, 200)
d = r.json()
cmd = d['command']
self.assertEqual((cmd['action'], cmd['value'], cmd['from_device_id'], cmd['from_device_name'], cmd['to_device_id']),
('seek', 61000, 'b', 'B', 'a'))
self.assertRegex(cmd['id'], r'^[0-9a-f]{32}$')
self.assertRegex(cmd['at'], r'Z$')
seq = d['seq']
# A report without it (the 10 s one) leaves it.
d = self.report('a', True, pos=2000).json()
self.assertEqual((d['seq'], d['command']), (seq + 1, cmd))
d = self.report('a', True, pos=61000, done_command='nope').json()
self.assertEqual(d['command'], cmd)
d = self.report('a', True, pos=61000, done_command=cmd['id']).json()
self.assertIsNone(d['command'])
self.assertEqual(d['seq'], seq + 3)
self.assertEqual(self.command('b', 'skip', -15).json()['command']['value'], -15)
def test_commands_to_a_paused_active_device(self):
# Device 1 plays, device 2 pauses it; "play" on device 2 resumes on device 1.
self.report('d1', True, pos=1000)
pause = self.command('d2', 'pause').json()['command']
self.report('d1', False, pos=3000, done_command=pause['id'])
d = self.command('d2', 'play').json()
self.assertEqual((d['command']['action'], d['command']['to_device_id']), ('play', 'd1'))
self.assertEqual((d['device_id'], d['playing'], d['position_ms']), ('d1', False, 3000))
self.assertIs(d['active_online'], True)
def test_command_needs_an_online_device(self):
r = self.command('b', 'pause')
self.assertEqual(r.status_code, 409) # nothing active
self.report('a', True)
self.go_offline('a')
self.assertEqual(self.command('b', 'pause').status_code, 409)
self.assertEqual(self.command('b', 'transfer', to='zzz').status_code, 409)
# Device a gone: device b's play wins (even without start).
d = self.report('b', True, pos=5).json()
self.assertEqual((d['device_id'], d['playing']), ('b', True))
def test_invalid_commands(self):
self.report('a', True)
for body in ({'device_id': 'b', 'action': 'louder'},
{'device_id': 'b', 'action': 'seek'},
{'device_id': 'b', 'action': 'skip', 'value': 'x'},
{'action': 'pause'}):
with self.subTest(body=body):
self.assertEqual(self.post('command', body).status_code, 400)
def test_transfer_to_another_device(self):
self.report('a', True, pos=1000)
self.report('c', False, pos=0) # c is online (paused report: ignored)
d = self.command('b', 'transfer', to='c').json()
cmd = d['command']
self.assertEqual((cmd['action'], cmd['to_device_id'], cmd['from_device_id']), ('transfer', 'c', 'b'))
self.assertEqual(d['device_id'], 'a')
# a keeps reporting meanwhile: the command stays for c.
self.assertEqual(self.report('a', True, pos=2000).json()['command'], cmd)
# c plays it from the place: c is the active device, the command is done.
d = self.report('c', True, pos=2100, done_command=cmd['id'], start=True).json()
self.assertEqual((d['device_id'], d['playing'], d['command']), ('c', True, None))
def test_transfer_done_in_the_report_that_takes_over(self):
self.report('a', True, pos=1000)
self.report('c', False)
cmd = self.command('b', 'transfer', to='c').json()['command']
d = self.report('c', True, pos=1100, start=True, done_command=cmd['id']).json()
self.assertEqual((d['device_id'], d['command']), ('c', None))
# a's periodic report, sent before it heard of the transfer: ignored.
self.assertEqual(self.report('a', True, pos=11000).json()['device_id'], 'c')
def test_transfer_that_could_not_play_is_done_too(self):
self.report('a', True)
self.report('c', False)
cmd = self.command('b', 'transfer', to='c').json()['command']
d = self.report('c', False, done_command=cmd['id']).json()
self.assertEqual((d['device_id'], d['command']), ('a', None))
def test_devices_coming_and_going_change_seq(self):
self.report('a', True)
seq = self.state().seq
views._check_online(self.user)
self.assertEqual(self.state().seq, seq) # nothing changed
self.report('a', True)
self.assertEqual(self.state().seq, seq + 1) # a report, no new device
self.go_offline('a')
views._check_online(self.user)
self.assertEqual(self.state().seq, seq + 2) # a went away
self.assertIs(self.client.get('/podcasts/player/').json()['active_online'], False)
def test_device_list(self):
now = dj_timezone.now()
for i in range(12):
PlayerDevice.objects.create(user=self.user, device_id=f'old{i}', device_name=f'Old {i}',
last_seen=now - timedelta(hours=i + 1))
PlayerDevice.objects.create(user=self.user, device_id='ancient', last_seen=now - timedelta(days=8))
self.report('z', True)
self.report('m', False)
devices = self.client.get('/podcasts/player/').json()['devices']
self.assertEqual(len(devices), 10)
self.assertEqual([d['id'] for d in devices[:3]], ['m', 'z', 'old0'])
self.assertNotIn('ancient', [d['id'] for d in devices])
def stream(self, since=None, **params):
if since is not None:
params['since'] = since
r = self.client.get('/podcasts/player/stream/', params)
self.assertEqual(r['Content-Type'], 'text/event-stream')
self.assertEqual(r['Cache-Control'], 'no-cache')
self.assertEqual(r['X-Accel-Buffering'], 'no')
events = []
for chunk in r.streaming_content:
text = chunk.decode()
if text.startswith('data: '):
events.append(json.loads(text[len('data: '):]))
else:
self.assertEqual(text, ': ping\n\n')
return events
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_sends_newer_state_and_ends(self):
self.report('a', True, pos=777)
seq = self.state().seq
events = self.stream(seq - 1)
self.assertEqual(len(events), 1)
self.assertEqual((events[0]['seq'], events[0]['position_ms'], events[0]['device_id']), (seq, 777, 'a'))
self.assertEqual(self.stream(seq), []) # nothing newer: pings only
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_sends_changes(self):
self.report('a', True)
seq = self.state().seq
sleep = views.time.sleep
changes = []
def change_once(seconds):
if not changes:
changes.append(1)
self.report('a', False, pos=42)
sleep(seconds)
with patch.object(views.time, 'sleep', change_once):
events = self.stream(seq)
self.assertEqual([(e['playing'], e['position_ms']) for e in events], [(False, 42)])
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_counts_as_presence(self):
events = self.stream(0, device_id='web1', device_name='Browser (Firefox/Linux)', device_kind='web')
self.assertEqual(len(events), 1) # web1 came online: seq 1
self.assertEqual(events[0]['devices'], [{'id': 'web1', 'name': 'Browser (Firefox/Linux)', 'online': True,
'active': False}])
self.assertEqual(PlayerDevice.objects.get(device_id='web1').kind, 'web')

View file

@ -30,8 +30,4 @@ urlpatterns = [
path('inbox/', views.inbox, name='podcast_inbox'),
path('import-downloads/', views.import_downloads, name='podcast_import_downloads'),
path('import-downloads/clear/', views.clear_import_downloads, name='podcast_clear_import_downloads'),
path('player/', views.player_state, name='podcast_player_state'),
path('player/report/', views.player_report, name='podcast_player_report'),
path('player/command/', views.player_command, name='podcast_player_command'),
path('player/stream/', views.player_stream, name='podcast_player_stream'),
]

View file

@ -1,12 +1,9 @@
import json
import logging
import math
import time
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone
from datetime import timedelta
import xml.etree.ElementTree as ET
import feedparser
@ -17,7 +14,7 @@ from django.db.models import (
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@ -26,8 +23,7 @@ from . import chapters as chapters_mod
from . import feedext
from . import transcripts as transcripts_mod
from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue,
PodcastSettings, counts_as_played,
EpisodeProgress, EpisodeTranscript, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played,
)
from .shownotes import extract_timestamps, sanitize_html
@ -1833,323 +1829,3 @@ def clear_import_downloads(request):
return JsonResponse({'error': 'authentication required'}, status=401)
PodcastSettings.objects.filter(user=request.user).update(import_downloads=[])
return JsonResponse({'ok': True})
# ---------------------------------------------------------------------------
# Player sync (like Spotify Connect): one player state per user. The device
# that starts playing becomes the active one and stays it, also while paused,
# as long as it is online. The others hear it through the stream, stop their
# own playback and show it as playing (or paused) there, with remote controls
# (command/), "play here" (a report of their own) and moving it to another
# device (command transfer). Presence: the stream and every report or command
# keep the device's last_seen fresh.
# ---------------------------------------------------------------------------
PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer')
PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
PLAYER_DEVICES_DAYS = 7
STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
STREAM_POLL_SECONDS = 1 # how often the stream looks for a new seq
STREAM_PING_SECONDS = 15 # a comment to keep the connection, and the device's presence
STREAM_PRESENCE_SECONDS = 5 # how often the stream looks for devices that went away
def _iso_ms(dt):
"""2026-10-02T12:00:00.123Z"""
if dt is None:
return None
dt = dt.astimezone(dt_timezone.utc)
return dt.strftime('%Y-%m-%dT%H:%M:%S.') + f'{dt.microsecond // 1000:03d}Z'
def _finite(value):
return _number(value) and math.isfinite(value)
def _online_since(now):
return now - timedelta(seconds=PLAYER_ONLINE_SECONDS)
def _online_ids(user, now):
return sorted(PlayerDevice.objects.filter(user=user, last_seen__gte=_online_since(now))
.values_list('device_id', flat=True))
def _seen(user, device_id, device_name='', kind=None, now=None):
"""The device is here (stream, ping, report, command)."""
if not device_id:
return
now = now or timezone.now()
fields = {'last_seen': now}
if device_name:
fields['device_name'] = device_name[:100]
if kind in {k for k, _ in PlayerDevice.KINDS if k}:
fields['kind'] = kind
# Single statements, no read-then-write transaction (update_or_create):
# under SQLite two of those at the same time end in "database is locked".
rows = PlayerDevice.objects.filter(user=user, device_id=device_id[:64])
if not rows.update(**fields):
PlayerDevice.objects.bulk_create([PlayerDevice(user=user, device_id=device_id[:64], **fields)],
ignore_conflicts=True)
rows.update(**fields) # someone else was quicker
def _check_online(user, now=None):
"""A device came or went: seq + 1, so the others see it. Returns the state."""
now = now or timezone.now()
online = _online_ids(user, now)
p = PlayerState.for_user(user)
if p.online_devices == online:
return p
return _change(p, online_devices=online)
def _change(p, **fields):
"""Write fields of the state and count the change: seq + 1 in the same
UPDATE, so that it only ever goes up, also with requests at the same
time. Returns the state as it is now."""
PlayerState.objects.filter(pk=p.pk).update(seq=F('seq') + 1, **fields)
p.refresh_from_db()
return p
def _is_online(user, device_id, now):
return bool(device_id) and PlayerDevice.objects.filter(
user=user, device_id=device_id, last_seen__gte=_online_since(now)).exists()
def _devices_json(user, p, now):
"""Online devices first (by name), then the last ones seen lately."""
since = _online_since(now)
rows = list(PlayerDevice.objects.filter(user=user, last_seen__gte=now - timedelta(days=PLAYER_DEVICES_DAYS))
.order_by('-last_seen').values('device_id', 'device_name', 'last_seen'))
online = sorted((r for r in rows if r['last_seen'] >= since), key=lambda r: (r['device_name'].casefold(), r['device_id']))
offline = [r for r in rows if r['last_seen'] < since]
return [
{'id': r['device_id'], 'name': r['device_name'], 'online': r['last_seen'] >= since,
'active': r['device_id'] == p.device_id}
for r in (online + offline)[:PLAYER_DEVICES_SHOWN]
]
def _player_json(user, p, now=None):
now = now or timezone.now()
episode = None
if p.kind == PlayerState.PODCAST and p.episode_id:
rows = list(PodcastEpisode.objects.filter(pk=p.episode_id).values(*_inbox_fields(False)))
episode = _add_user_state(user, rows)[0] if rows else None
station = None
if p.kind == PlayerState.RADIO:
station = {'url': p.station_url, 'name': p.station_name, 'id': p.station_id}
devices = _devices_json(user, p, now)
return {
'seq': p.seq,
'kind': p.kind,
'episode': episode,
'station': station,
'position_ms': p.position_ms,
'playing': p.playing,
'speed': p.speed,
'device_id': p.device_id,
'device_name': p.device_name,
'updated_at': _iso_ms(p.updated_at),
'server_time': _iso_ms(now),
'command': p.command,
'devices': devices,
'active_online': _is_online(user, p.device_id, now),
}
def _player_body(request):
"""The JSON body and the sending device, or an error response."""
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return None, JsonResponse({'error': 'invalid JSON'}, status=400)
if not isinstance(body, dict):
return None, JsonResponse({'error': 'invalid JSON'}, status=400)
device_id = body.get('device_id')
if not isinstance(device_id, str) or not device_id.strip() or len(device_id) > 64:
return None, JsonResponse({'error': 'device_id required'}, status=400)
body['device_name'] = str(body.get('device_name') or '')[:100]
return body, None
@csrf_exempt
@require_http_methods(['GET'])
def player_state(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return JsonResponse(_player_json(request.user, PlayerState.for_user(request.user)))
@csrf_exempt
@require_http_methods(['POST'])
def player_report(request):
"""A device says what it plays. Accepted from the active device; from
another one when playback was just started there (start: by the user, a
command, a transfer — it becomes the active device), or when the active
device is gone (offline, or there is none): then the newest report wins.
Otherwise ignored, such as a periodic "playing" report of a device that
was just taken over. The answer is the state either way."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
if error:
return error
kind = body.get('kind') or ''
if kind not in {k for k, _ in PlayerState.KINDS}:
return JsonResponse({'error': 'unknown kind'}, status=400)
playing = body.get('playing')
if not isinstance(playing, bool):
return JsonResponse({'error': 'playing must be true or false'}, status=400)
position = body.get('position_ms', 0)
if not _finite(position):
return JsonResponse({'error': 'position_ms must be a number'}, status=400)
speed = body.get('speed')
if speed is None:
speed = 1
if not _finite(speed) or not 0.1 <= speed <= 10:
return JsonResponse({'error': 'invalid speed'}, status=400)
episode_id = None
station = ('', '', None)
if kind == PlayerState.PODCAST:
episode_id = body.get('episode_id')
if not _number(episode_id) or episode_id != int(episode_id) or not \
PodcastEpisode.objects.filter(pk=int(episode_id), feed__user=request.user).exists():
return JsonResponse({'error': 'unknown episode'}, status=400)
episode_id = int(episode_id)
elif kind == PlayerState.RADIO:
st = body.get('station')
if not isinstance(st, dict) or not isinstance(st.get('url'), str) or not st['url'].strip():
return JsonResponse({'error': 'station.url required'}, status=400)
sid = st.get('id')
station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
int(sid) if _number(sid) and sid == int(sid) else None)
device_id = body['device_id']
done = body.get('done_command')
start = body.get('start') is True
now = timezone.now()
_seen(request.user, device_id, body['device_name'], now=now)
p = PlayerState.for_user(request.user)
done = bool(done) and bool(p.command) and done == p.command.get('id')
if device_id == p.device_id or (playing and start) or not _is_online(request.user, p.device_id, now):
p = _change(
p,
kind=kind,
episode_id=episode_id,
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),
speed=float(speed),
device_id=device_id,
device_name=body['device_name'],
updated_at=now,
# Done (also a transfer, done in the report that makes its target
# the active device), or meant for the device before.
command=None if done or device_id != p.device_id else p.command,
)
elif done:
# A device that isn't the active one did its command (a transfer
# that couldn't play): done all the same.
p = _change(p, command=None)
p = _check_online(request.user, now)
return JsonResponse(_player_json(request.user, p, now))
@csrf_exempt
@require_http_methods(['POST'])
def player_command(request):
"""Remote control: a command for a device (to_device_id, else the active
one), which carries it out and reports it done (done_command). Only to a
device that is online. transfer: the device plays the shared item from
its place and so becomes the active one."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
if error:
return error
action = body.get('action')
if action not in PLAYER_ACTIONS:
return JsonResponse({'error': 'unknown action'}, status=400)
value = None
if action in ('seek', 'skip'):
value = body.get('value')
if not _finite(value):
return JsonResponse({'error': 'value must be a number'}, status=400)
value = max(int(value), 0) if action == 'seek' else int(value)
to = body.get('to_device_id')
if to is not None and (not isinstance(to, str) or len(to) > 64):
return JsonResponse({'error': 'invalid to_device_id'}, status=400)
now = timezone.now()
_seen(request.user, body['device_id'], body['device_name'], now=now)
p = _check_online(request.user, now)
to = to or p.device_id
if not to or not _is_online(request.user, to, now):
return JsonResponse({'error': 'device not online'}, status=409)
if action == 'transfer' and not p.kind:
return JsonResponse({'error': 'nothing to transfer'}, status=409)
p = _change(p, command={
'id': uuid.uuid4().hex,
'action': action,
'value': value,
'from_device_id': body['device_id'],
'from_device_name': body['device_name'],
'to_device_id': to,
'at': _iso_ms(now),
})
return JsonResponse(_player_json(request.user, p, now))
@csrf_exempt
@require_http_methods(['GET'])
def player_stream(request):
"""Server-sent events: the state at once if it is newer than ?since=<seq>,
then on every change; a ping every 15 s, and the end after a few minutes
(the client reconnects with the last seq it has). With device_id (and
device_name, device_kind app|web) the device counts as online meanwhile."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
since = int(request.GET.get('since', -1))
except ValueError:
return JsonResponse({'error': 'since must be a number'}, status=400)
user = request.user
device_id = request.GET.get('device_id', '')[:64]
_seen(user, device_id, request.GET.get('device_name', ''), request.GET.get('device_kind'))
_check_online(user)
def events():
last = since
first = True
start = pinged = checked = time.monotonic()
while True:
seq = PlayerState.objects.filter(user=user).values_list('seq', flat=True).first()
if seq is not None and (seq > last if first else seq != last):
p = PlayerState.objects.filter(user=user).first()
last = p.seq
yield f'data: {json.dumps(_player_json(user, p))}\n\n'
elif first:
yield ': ping\n\n' # the headers go out now: the client knows it's connected
first = False
now = time.monotonic()
if now - start >= STREAM_SECONDS:
return
if now - pinged >= STREAM_PING_SECONDS:
pinged = now
_seen(user, device_id)
yield ': ping\n\n'
if now - checked >= STREAM_PRESENCE_SECONDS:
checked = now
_check_online(user)
time.sleep(STREAM_POLL_SECONDS)
response = StreamingHttpResponse(events(), content_type='text/event-stream')
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response

View file

@ -73,6 +73,7 @@
let state = load('state', null) || empty();
let outbox = load('outbox', []);
let lastRefresh = 0;
function save() {
store('state', state);
@ -151,9 +152,9 @@
const fresh = await r.json();
if (outbox.length) continue;
state = fresh;
lastRefresh = Date.now();
save();
changed();
followSavedPlace();
return true;
}
return false;
@ -619,7 +620,7 @@
lastPos = resumeMs();
storePlayer();
if (play && !stop) resume();
else { wantPlay = false; report(); syncSoon(); }
else { wantPlay = false; report(); }
}
// A pause rewinds a little on the way back in, to find the thread again.
@ -638,7 +639,6 @@
if (!loaded) prepare();
else catchUp();
wantPlay = true;
sync.start = true;
applyKind();
setSession();
const p = audio.play();
@ -649,11 +649,9 @@
buffering = false;
if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen');
report();
syncSoon();
});
}
report();
syncSoon();
}
function pause() {
@ -661,7 +659,6 @@
if (!audio.paused) audio.pause();
pausedAt = pausedAt || Date.now();
report();
syncSoon();
}
function seek(ms) {
@ -674,10 +671,7 @@
startAt = ms;
}
lastPos = ms;
// A place chosen while paused is kept at once (playing, it's saved every 10 s).
if (!wantPlay) saveNow(cur.episode__id, true);
report();
syncSoon();
}
function skip(forward) {
@ -721,7 +715,6 @@
sleep.queueEnded();
wantPlay = false;
report();
syncSoon();
}
// A browser that pauses the element while it loads (power saving) may drop
@ -747,12 +740,10 @@
audio.addEventListener('pause', () => {
if (!loaded || audio.ended) return;
// Paused here, by the lock screen, or by the system (headphones out …).
const was = wantPlay;
wantPlay = false;
pausedAt = pausedAt || Date.now();
if (cur) saveNow(cur.episode__id, true);
report();
if (was) syncSoon();
});
audio.addEventListener('ended', onEnded);
audio.addEventListener('loadedmetadata', () => {
@ -772,7 +763,6 @@
buffering = false;
toast('Diese Folge lässt sich gerade nicht abspielen');
report();
syncSoon();
});
// Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer.
@ -787,10 +777,7 @@
}
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
// While playing, the shared player hears from us every 10 s (its place, and that we're alive).
if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport();
// Another device's place moves on in the bar too.
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
if (playing || sleep.leftMs > 0) report();
}, 1000);
// ---- radio and podcasts take turns ---------------------------------------------------
@ -824,8 +811,6 @@
putAway();
const r = playStation.apply(this, arguments);
radioChanged();
sync.start = true;
syncSoon();
return r;
};
}
@ -877,482 +862,9 @@
return r;
};
}
// The radio stopping is news for the shared player too.
if (typeof window.stopPlayback === 'function') {
const stopPlayback = window.stopPlayback;
window.stopPlayback = function () {
const was = radioPlaying();
const r = stopPlayback.apply(this, arguments);
if (was) syncSoon();
return r;
};
}
const savedRows = document.getElementById('saved-tbody');
if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true});
// Player sync: the station another device played last becomes the current
// one here, without playing (the bar shows it with ▶).
window.__dioraAdoptStation = function (st) {
if (!st || !st.url || radioPlaying() || typeof currentStation === 'undefined') return;
currentStation = {url: st.url, name: st.name || '', id: st.id || null};
currentTrack = '';
const name = document.getElementById('now-playing-station');
if (name) name.textContent = currentStation.name;
const track = document.getElementById('now-playing-track');
if (track) track.textContent = '';
const b = document.getElementById('play-stop-btn');
if (b) {
b.style.display = '';
b.textContent = '▶ Play';
b.classList.remove('playing');
}
radioChanged();
};
// ---- player sync: one player for all devices (like Spotify Connect) ------------------
// The server keeps what plays (/podcasts/player/, see podcasts/views.py). The
// device that starts playing becomes the active one and stays it while it is
// online, also paused. Every other device stops its own playback and shows
// the active one's as "remote" (playerJson): its buttons send commands
// there, "Hier abspielen" plays it here from its place, the device sheet
// moves it anywhere (transfer). When the active device is gone, what it
// played last is loaded here, paused. In the app PlayerSync.kt does this.
const REPORT_EVERY_MS = 10000;
const OLD_COMMAND_MS = 60000; // a command this old (the page was away) is only acknowledged
function deviceId() {
let id = '';
try { id = localStorage.getItem('diora_device_id') || ''; } catch (e) {}
if (!id) {
id = 'web-' + Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
try { localStorage.setItem('diora_device_id', id); } catch (e) {}
}
return id;
}
function browserName() {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web';
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows'
: /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';
return os ? browser + '/' + os : browser;
}
const DEVICE_ID = deviceId();
const DEVICE_NAME = 'Browser (' + browserName() + ')';
const sync = {
state: null, // the server's state, as last taken in
heardAt: 0, // when it came (this clock only measures the time since)
later: null, // the newest state that came while a report of ours was on its way
ownAt: load('sync_at', ''), // server time of our last report: what the others did before is old
sending: 0, // reports on their way
timer: 0, // a report about to go
lastSent: 0,
quiet: false, // playback changes the sync makes itself: no reports of their own
doneCmd: null, // the command the next report says is done
start: false, // playback was just started here (by the user, a command): we take over
executed: new Set(),
hint: null, // what a command of ours will change, shown until the device reports
};
const busy = () => sync.sending > 0 || !!sync.timer;
function isoMs(iso) {
const t = Date.parse(iso || '');
return isFinite(t) ? t : 0;
}
// The server's clock now: its time when the state came plus what has passed since.
function serverNow(st) {
return isoMs(st.server_time) + (Date.now() - sync.heardAt);
}
// Where the state's item is now: while it plays, its last place plus the time since.
function placeOf(st) {
let pos = st.position_ms || 0;
if (st.playing && st.active_online && st.updated_at) {
pos += Math.max(0, serverNow(st) - isoMs(st.updated_at)) * (st.speed || 1);
}
const dur = st.episode ? (st.episode.duration_seconds || 0) * 1000 : 0;
return Math.round(dur > 0 ? Math.min(pos, dur) : pos);
}
// Another device is the active one, it is online and has something.
function otherActive(st) {
return !!(st && st.device_id && st.device_id !== DEVICE_ID && st.active_online &&
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
}
// The page's player.remote: what the other device plays.
function remoteJson() {
const st = sync.state;
if (!otherActive(st)) return null;
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
const radio = st.kind === 'radio';
if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
o.title = o.station.name;
} else {
const e = st.episode;
o.episodeId = e.id;
o.feedId = e.feed__id || 0;
o.title = plain(e.title);
o.feed = plain(e.feed__title);
o.artwork = e.artwork_url || e.feed__artwork_url || '';
o.dur = (e.duration_seconds || 0) * 1000;
o.pos = placeOf(st);
}
// A command of ours shows at once, until the device reports back.
const h = sync.hint;
if (h && h.updatedAt === st.updated_at && Date.now() < h.until) {
o.playing = h.playing;
if (!radio) {
o.pos = Math.round(h.pos + (o.playing ? (Date.now() - h.at) * (st.speed || 1) : 0));
o.pos = Math.max(0, o.dur > 0 ? Math.min(o.pos, o.dur) : o.pos);
}
}
return o;
}
// The page's player.devices: the server's list, with this browser marked (and in it).
function devicesJson() {
const st = sync.state;
const list = ((st && st.devices) || []).map(d =>
(d.id === DEVICE_ID ? Object.assign({}, d, {me: true, online: true}) : Object.assign({}, d)));
if (!list.some(d => d.me)) {
list.unshift({id: DEVICE_ID, name: DEVICE_NAME, online: true, active: !!st && st.device_id === DEVICE_ID, me: true});
}
return list;
}
// What this device plays, as a report has it.
function mine() {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
if (st && (radioPlaying() || !cur)) {
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
position_ms: 0, playing: radioPlaying()};
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed())};
}
return {kind: '', position_ms: 0, playing: false};
}
function post(path, body) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify(body),
});
}
// This device plays: it is the active one now (the server's answer will say so too).
function claim() {
const st = sync.state;
if (st && st.device_id !== DEVICE_ID) {
sync.state = Object.assign({}, st, {device_id: DEVICE_ID, device_name: DEVICE_NAME, playing: true, command: null});
}
sync.hint = null;
}
// Something changed here: tell the server, a moment later so that a burst
// (switching stations, several skips) goes as one report.
function syncSoon() {
if (sync.quiet) return;
if (sync.start && mine().playing) claim();
if (!sync.timer) sync.timer = setTimeout(syncReport, 300);
}
function syncReport() {
clearTimeout(sync.timer);
sync.timer = 0;
const body = Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, mine());
const done = sync.doneCmd;
if (done) body.done_command = done;
// Only a start makes us the active device; a late 10 s report after
// another device took over doesn't take it back.
if (body.playing && sync.start) {
body.start = true;
claim();
}
sync.start = false;
sync.lastSent = Date.now();
sync.sending++;
return post('/podcasts/player/report/', body)
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(st => {
sync.sending--;
if (st) {
if (done && sync.doneCmd === done) sync.doneCmd = null;
// Only a report the server took is our last change (an ignored one isn't).
if (st.device_id === DEVICE_ID) {
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
}
heard(st);
return st;
});
}
// A state from the server (the stream, or the answer to a report or command).
// While a report of ours is on its way, states wait for its answer: they may
// be from before it, and would stop what we just started.
function heard(st) {
if (st && typeof st.seq === 'number' && (!sync.later || st.seq >= sync.later.seq)) sync.later = st;
if (busy() || !sync.later) return;
st = sync.later;
sync.later = null;
if (sync.state && st.seq < sync.state.seq) return;
sync.state = st;
sync.heardAt = Date.now();
act(st);
report();
}
// What a state means for this device.
function act(st) {
const cmd = st.command;
if (cmd && cmd.id && !sync.executed.has(cmd.id) && (cmd.to_device_id || st.device_id) === DEVICE_ID) {
sync.executed.add(cmd.id);
if (serverNow(st) - isoMs(cmd.at) > OLD_COMMAND_MS) {
sync.doneCmd = cmd.id;
syncReport();
} else {
execute(cmd, st);
}
return;
}
if (st.device_id === DEVICE_ID) {
// The server thinks we play, but we don't (this page was reloaded): say so.
if (st.playing && !mine().playing) syncSoon();
return;
}
if (!st.device_id) return;
const newer = isoMs(st.updated_at) > isoMs(sync.ownAt);
if (st.active_online) {
if (st.playing) handOver(st);
// Paused there on the episode that waits here too: ours goes to its place.
else if (newer && st.kind === 'podcast' && st.episode) movePausedTo(st.episode.id, st.position_ms);
return;
}
// The active device is gone: what it had last comes here, if it's newer than our own last change.
if (newer) adopt(st);
}
// A command for this device: carry it out, then one report that says it's done.
function execute(cmd, st) {
const kind = mine().kind;
const a = cmd.action;
sync.doneCmd = cmd.id;
sync.quiet = true;
try {
if (a === 'transfer') {
takeOver(st);
} else if (kind === 'radio') {
if (a === 'play' && !radioPlaying()) togglePlayStop();
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
} else if (kind === 'podcast') {
if (a === 'play') resume();
else if (a === 'pause' || a === 'stop') pause();
else if (a === 'seek') seek(Number(cmd.value) || 0);
else if (a === 'skip') {
const to = posMs() + (Number(cmd.value) || 0) * 1000;
const dur = durMs();
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
} else if (a === 'next') next();
}
} finally {
sync.quiet = false;
}
syncReport();
}
// Another device started playing: this one stops (the episode keeps its place).
function handOver(st) {
if (!wantPlay && !radioPlaying()) return;
sync.quiet = true;
try {
if (wantPlay) pause();
if (radioPlaying()) stopPlayback(false);
} finally {
sync.quiet = false;
}
toast('Läuft jetzt auf ' + (st.device_name || 'einem anderen Gerät'));
}
// Load an episode paused at a place (adopting, or before playing it here).
function loadAt(ep, at) {
at = Math.max(0, Math.round(at || 0));
if (curId() === ep.id) {
if (loaded && audio.readyState > 0) {
try { audio.currentTime = at / 1000; } catch (e) {}
} else {
startAt = at;
}
} else {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
startAt = at;
wantPlay = false;
buffering = false;
error = null;
lastDur = 0;
storePlayer();
}
mustReach = null;
pausedAt = 0;
lastPos = at;
cur.position_seconds = Math.floor(at / 1000);
forEpisode(state, ep.id, o => { o.position_seconds = Math.floor(at / 1000); });
save();
report();
}
// The episode that waits here, paused, follows a newer place from elsewhere
// (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 (outbox.some(op => op.key === 'progress:' + id)) return false;
if (Math.abs(at - posMs()) <= 5000) return false;
loadAt({id}, at);
return true;
}
// After fetching the podcast state: the saved place of that episode may be newer.
function followSavedPlace() {
if (!cur) return;
let secs = null;
forEpisode(state, cur.episode__id, o => { if (secs == null && o.position_seconds != null) secs = o.position_seconds; });
if (secs == null) return;
// Where resuming would start (resumeMs): from 5 s on, past the podcast's intro.
movePausedTo(cur.episode__id, Math.max(secs > 5 ? secs * 1000 : 0, introMs(curFeed())));
}
// Play what the shared player has, here, from its place ("Hier abspielen", a transfer).
function takeOver(st) {
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) {
loadAt(st.episode, placeOf(st));
resume();
} else if (cur) {
resume();
}
}
// 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 (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) {
const now = typeof currentStation !== 'undefined' ? currentStation : null;
if (cur || !now || now.url !== st.station.url) {
sync.quiet = true;
try { putAway(); } finally { sync.quiet = false; }
window.__dioraAdoptStation(st.station);
}
}
sync.ownAt = st.server_time;
store('sync_at', sync.ownAt);
}
// What a command of ours will do shows at once (the device reports within a second or so).
function hintFor(action, value) {
const st = sync.state;
const r = remoteJson();
if (!r) return;
const h = {updatedAt: st.updated_at, until: Date.now() + 5000, at: Date.now(), pos: r.pos, playing: r.playing};
if (action === 'play') h.playing = true;
else if (action === 'pause' || action === 'stop') h.playing = false;
else if (action === 'seek') h.pos = value;
else if (action === 'skip') h.pos = Math.max(0, r.pos + value * 1000);
else return;
sync.hint = h;
report();
}
// Remote control: a command for the active device (or `to`).
function command(action, value, to) {
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
if (to) body.to_device_id = to;
else hintFor(action, body.value);
return post('/podcasts/player/command/', body)
.then(r => {
if (r.ok) return r.json();
return r.json().catch(() => ({})).then(d => {
toast(d && d.error === 'nothing to transfer' ? 'Hier läuft nichts, das sich verschieben ließe'
: 'Das Gerät ist gerade nicht erreichbar');
return null;
});
})
.catch(() => { toast('Keine Verbindung zum Server'); return null; })
.then(st => {
if (!st) { sync.hint = null; report(); }
heard(st);
});
}
// Move the playback to a device: here (take over), or there (it plays from the place, we stop).
function transfer(id) {
if (id === DEVICE_ID) { takeOver(); return; }
// What we have isn't shared yet (we're the active device, or there is none): first say what it is.
(otherActive(sync.state) ? Promise.resolve() : syncReport()).then(() => command('transfer', 0, id));
}
// The stream: every change, as it happens. The server ends it every few minutes
// (and the network may drop it): then again, with the last seq we know.
let source = null;
let retryMs = 1000;
let retryTimer = 0;
function listen() {
clearTimeout(retryTimer);
if (source) source.close();
const seen = Math.max(sync.state ? sync.state.seq : -1, sync.later ? sync.later.seq : -1);
const q = new URLSearchParams({since: String(seen), device_id: DEVICE_ID, device_name: DEVICE_NAME, device_kind: 'web'});
source = new EventSource('/podcasts/player/stream/?' + q);
source.onopen = () => { retryMs = 1000; };
source.onmessage = e => heard(parse(e.data, null));
source.onerror = () => {
if (source) source.close();
source = null;
retryTimer = setTimeout(listen, retryMs);
retryMs = Math.min(retryMs * 2, 30000);
};
}
function fetchPlayer() {
return fetch('/podcasts/player/', {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(heard);
}
function startSync() {
fetchPlayer().then(listen);
}
// Leaving while playing: the others shouldn't show us as playing until we count as gone.
function syncLeaving() {
const m = mine();
if (!m.playing || !sync.state || sync.state.device_id !== DEVICE_ID || !navigator.sendBeacon) return;
m.playing = false;
navigator.sendBeacon('/podcasts/player/report/', JSON.stringify(Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, m)));
}
// ---- what the page and the lock screen see ---------------------------------------------
const decoder = document.createElement('textarea');
@ -1364,8 +876,7 @@
function playerJson() {
const fid = curFeed();
const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json(),
remote: remoteJson(), devices: devicesJson()};
const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json()};
if (!cur) { o.kind = null; return o; }
o.kind = 'podcast';
o.episodeId = cur.episode__id;
@ -1500,10 +1011,6 @@
},
podcastSleepExtend(minutes) { sleep.extend(minutes); report(); },
podcastSleepAuto(on) { prefs.sleep_auto = !!on; store('prefs', prefs); },
// Player sync: control the device that plays, play it here, move it to a device.
playerRemote: (action, value) => { command(String(action), Number(value)); },
playerTakeOver: () => takeOver(),
playerTransfer: id => transfer(String(id)),
isEinkDevice: () => false,
// No podcastDownloads: the browser streams; the page hides everything about downloads.
};
@ -1623,26 +1130,14 @@
}
};
window.addEventListener('pagehide', leaving);
window.addEventListener('pagehide', syncLeaving);
// Back in the foreground: another device may have played meanwhile (its place, what plays).
let lastVisible = 0;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
} else if (Date.now() - lastVisible > 15000) {
lastVisible = Date.now();
} else if (Date.now() - lastRefresh > 5 * 60000) {
refresh(false);
fetchPlayer();
if (!source) listen();
}
});
window.addEventListener('online', () => {
flush();
if (!source) listen();
});
// The shared player: what plays on the other devices, and from now on every change.
startSync();
window.addEventListener('online', () => { flush(); });
// The page (copied from the app) comes last, once all of the above is in place.
const script = document.createElement('script');

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v60';
const CACHE = 'diora-v57';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
@ -65,7 +65,6 @@ self.addEventListener('fetch', function (event) {
url.pathname.startsWith('/admin/') ||
url.pathname.startsWith('/podcasts/progress/') ||
url.pathname.startsWith('/podcasts/queue/') ||
url.pathname.startsWith('/podcasts/player/') ||
url.pathname === '/podcasts/feeds/add' ||
url.pathname.startsWith('/podcasts/feeds/add') ||
url.pathname.includes('/remove') ||

View file

@ -1,4 +1,4 @@
// Copied from diora-android 38d03c6 2026-10-02: web-native/static/native-podcast.js
// Copied from diora-android 6c2b071 2026-10-02: 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.
@ -173,8 +173,7 @@
// ---- actions ---------------------------------------------------------------------------
function play(e) {
const r = remote();
if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); return; }
if (currentId() === e.id) { openPlayer(); return; }
N.podcastPlayEpisode(JSON.stringify(e.raw), currentId());
}
@ -1303,16 +1302,11 @@
text.type = 'button';
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
text.addEventListener('click', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true;
mini.append(art('', 'mini'), text,
button('↺', '15 s zurück', () => (miniKind === 'remote'
? remoteCmd('skip', -(devicePrefs().skip_back || 15)) : N.podcastSkip(false)), 'nui-pod-skip'),
button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'),
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
devices, here,
button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
button('▶', 'Abspielen', () => (miniKind === 'radio'
? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
dock.prepend(mini);
// One player for podcasts and radio: the radio's own bar steps aside.
document.documentElement.classList.add('nui-one-player');
@ -1329,8 +1323,7 @@
}
}
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
// (another device is the active one) or ''.
// What the bar shows: 'podcast', 'radio' (a station is chosen) or ''.
let miniKind = '';
function radioNow() {
@ -1352,32 +1345,16 @@
}
function renderMini() {
const r = remote();
const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
document.documentElement.classList.toggle('nui-pod-playing', isPod);
if (!mini) return;
const radio = isPod || r ? null : radioNow();
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '';
const radio = isPod ? null : radioNow();
miniKind = isPod ? 'podcast' : radio ? 'radio' : '';
mini.hidden = !miniKind;
mini.classList.toggle('nui-mini-radio', miniKind === 'radio' || (!!r && r.kind === 'radio'));
mini.classList.toggle('nui-mini-remote', !!r);
mini.classList.toggle('nui-mini-radio', miniKind === 'radio');
document.documentElement.classList.toggle('nui-pod-mini-open', !!miniKind);
mini.querySelector('.nui-pod-skip').hidden = !(isPod || r) || (!!r && r.kind !== 'podcast');
mini.querySelector('.nui-pod-skip').hidden = miniKind !== 'podcast';
mini.querySelector('.nui-mini-save').hidden = miniKind !== 'radio' || radio.saved;
mini.querySelector('.nui-mini-devices').hidden = !r || !N.playerTransfer;
mini.querySelector('.nui-mini-here').hidden = !r || !N.playerTakeOver;
if (r) {
const onRadio = r.kind === 'radio';
mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
mini.querySelector('.nui-mini-track').textContent = remoteLine(r);
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r);
setArt(mini.querySelector('.nui-pod-art'), onRadio ? r.station && r.station.logo : r.artwork);
mini.querySelector('.nui-pod-mini-bar span').style.width = onRadio ? (r.playing ? '100%' : '0')
: r.dur ? Math.min(100, r.pos / r.dur * 100) + '%' : '0';
return;
}
if (miniKind === 'radio') {
mini.querySelector('.nui-mini-station').textContent = radio.name;
mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …'
@ -1660,7 +1637,6 @@
let sheetKind = '';
function openPlayer() {
if (remote()) { openRemotePlayer(); return; }
if (player.kind !== 'podcast') return;
closePlayer();
sheetKind = 'podcast';
@ -1699,7 +1675,7 @@
);
panel.append(controls);
const extra = el('div', 'nui-pod-extra');
extra.append(...[
extra.append(
button('1×', 'Geschwindigkeit', speedMenu, 'nui-pod-speed'),
button('☾', 'Sleep-Timer', sleepMenu, 'nui-pod-sleep'),
button('☆', 'Favorit', () => toggleFavorite({id: player.episodeId}, star => {
@ -1707,9 +1683,8 @@
if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); }
}), 'nui-pod-fav'),
button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'),
N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'),
button('≡', 'Warteschlange', showQueue),
].filter(Boolean));
button('≡', 'Warteschlange', () => { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); }),
);
panel.append(extra);
const notes = el('div', 'nui-pod-notesbox');
panel.append(notes);
@ -1755,7 +1730,6 @@
stations.type = 'button';
stations.addEventListener('click', () => { closePlayer(); showTab('radio'); });
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);
@ -1789,24 +1763,10 @@
renderMini();
if (sheet && sheetKind === 'radio') renderRadioPlayer();
};
window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? openRadioPlayer() : openPlayer());
window.__dioraOpenPlayer = kind => (kind === 'radio' ? openRadioPlayer() : openPlayer());
function renderPlayer() {
if (!sheet) return;
const r = remote();
if (sheetKind === 'remote') {
// Back here (taken over) or the other device went away: this device's player.
if (!r) {
closePlayer();
if (player.kind === 'podcast') openPlayer(); else if (radioNow()) openRadioPlayer();
} else if (sheet.dataset.remote !== remoteKey(r)) {
openRemotePlayer();
} else {
renderRemotePlayer();
}
return;
}
if (r) { openRemotePlayer(); return; }
if (sheetKind === 'radio') {
if (player.kind === 'podcast') closePlayer(); else renderRadioPlayer();
return;
@ -1909,171 +1869,6 @@
N.podcastMarkPlayed(id, true);
}
function showQueue() {
closePlayer();
view = 'queue';
feedView = null;
showTab('podcasts');
render();
}
// ---- another device plays (player sync, like Spotify Connect) ------------------------------
// The host (Bridge.kt / podcasts-web.js) shares one player among the user's
// devices. While another device is the active one (online, playing or
// paused), player.remote is what it plays: the bar and the player show it,
// their buttons control it (N.playerRemote), "Hier abspielen" plays it here
// from its place, and the device sheet moves it to any device that is online.
function remote() {
return player.remote && N.playerRemote ? player.remote : null;
}
function remoteName(r) {
return r.device || 'einem anderen Gerät';
}
function remoteKey(r) {
return r.kind + ':' + (r.kind === 'radio' ? (r.station && r.station.url) || '' : r.episodeId);
}
function remoteLine(r) {
return (r.playing ? '▸ läuft auf ' : '‖ pausiert auf ') + remoteName(r);
}
function remoteCmd(action, value) {
if (N.playerRemote) N.playerRemote(action, Math.round(value || 0));
}
function remoteToggle() {
const r = remote();
if (r) remoteCmd(r.playing ? 'pause' : 'play');
}
function takeOver() {
if (N.playerTakeOver) N.playerTakeOver();
}
// Which device plays: the ones online, this one first; choosing one moves the playback there.
function deviceMenu() {
if (!N.playerTransfer) return;
const all = (player.devices || []).filter(d => d.online || d.me);
const devices = all.filter(d => d.me).concat(all.filter(d => !d.me));
const r = remote();
const radio = r ? null : radioNow();
const activePlays = r ? r.playing : player.kind === 'podcast' ? !!player.playing : !!(radio && radio.playing);
const items = devices.map(d => ({
icon: d.active ? '◉' : '○',
label: d.me ? 'Dieses Gerät' : d.name || 'Unbekanntes Gerät',
hint: d.active ? (activePlays ? 'spielt gerade' : 'pausiert') : d.me ? null : 'hierhin verschieben',
active: !!d.active,
action: () => N.playerTransfer(d.id),
}));
if (devices.length < 2) {
items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote',
'Keine anderen Geräte online. Sobald diora auf einem anderen Gerät offen ist, erscheint es hier.')});
}
openSheet('Wiedergabe auf …', items);
}
function devicesButton() {
const b = el('button', 'btn nui-pod-devbtn', '⇄ Wiedergabe auf …');
b.type = 'button';
b.addEventListener('click', deviceMenu);
return b;
}
// The full player for what another device plays: the same layout, remote buttons.
function openRemotePlayer() {
const r = remote();
if (!r) return;
closePlayer();
sheetKind = 'remote';
const onRadio = r.kind === 'radio';
sheet = el('div');
sheet.id = 'nui-pod-player';
sheet.className = 'nui-pod-remote';
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'));
const controls = el('div', 'nui-pod-controls');
if (onRadio) {
controls.append(button('▶', 'Abspielen', remoteToggle, 'nui-pod-play'));
} else {
panel.append(el('div', 'nui-pod-player-feed nui-pod-remote-feed'));
const range = el('input', 'nui-pod-seek');
range.type = 'range';
range.min = 0;
range.step = 1;
range.addEventListener('input', () => {
seeking = true;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value);
});
range.addEventListener('change', () => {
remoteCmd('seek', range.value * 1000);
seeking = false;
});
const times = el('div', 'nui-pod-times');
times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left'));
panel.append(range, times);
const prefs = devicePrefs();
const back = prefs.skip_back || 15;
const fwd = prefs.skip_forward || 30;
controls.append(
button(String(back), `${back} Sekunden zurück`, () => remoteCmd('skip', -back), 'nui-pod-back'),
button('▶', 'Abspielen', remoteToggle, 'nui-pod-play'),
button(String(fwd), `${fwd} Sekunden vor`, () => remoteCmd('skip', fwd), 'nui-pod-fwd'),
);
}
panel.append(controls);
if (N.playerTakeOver) {
const here = el('button', 'btn nui-pod-here', '⇩ Hier abspielen');
here.type = 'button';
here.addEventListener('click', takeOver);
panel.append(here);
}
const extra = el('div', 'nui-pod-extra');
if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next'));
if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'));
if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue));
panel.append(extra);
if (!onRadio && r.episodeId) {
const notes = el('div', 'nui-pod-notesbox');
panel.append(notes);
showDetails(notes, r.episodeId, false);
}
sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
document.body.append(sheet);
renderRemotePlayer();
}
function renderRemotePlayer() {
const r = remote();
if (!r || !sheet) return;
const onRadio = r.kind === 'radio';
sheet.querySelector('.nui-pod-player-title').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
sheet.querySelector('.nui-pod-remote-line').textContent = remoteLine(r);
const p = sheet.querySelector('.nui-pod-play');
p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r);
if (onRadio) return;
sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || '';
const range = sheet.querySelector('.nui-pod-seek');
const dur = Math.round((r.dur || 0) / 1000);
const pos = Math.round((r.pos || 0) / 1000);
range.max = dur || 1;
if (!seeking) {
range.value = pos;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos);
}
sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : '';
markChapter(sheet, pos);
}
// ---- downloads (Downloads.kt) ------------------------------------------------------------
// Episodes kept on the phone to play without a connection. Each episode row
// gets a mark (↓ fetch it, a ring while it loads, ✓ when it's here), the ⋯

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 57fc424 2026-10-02: the podcast parts of web-native/static/native.css
/* Copied from diora-android e6761d0 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@ -132,6 +132,9 @@ html.nui-one-player #nui-mini { display: none !important; }
.nui-radio-live { text-align: center; font-size: 13px; color: var(--nui-muted); letter-spacing: 0.04em; }
.nui-radio-live.on { color: var(--accent); font-weight: 700; }
.nui-radio-stations { align-self: center; }
html.nui-radio.nui-pod-mini-open body {
padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px));
}
#nui-pod-bar {
display: flex;
@ -335,18 +338,6 @@ html.nui-one-player #nui-mini { display: none !important; }
.nui-pod-extra { display: flex; justify-content: space-around; }
.nui-pod-speed { font-size: 15px !important; font-weight: 700; min-width: 64px; }
/* Another device plays (player sync): the bar and the player show it and control it there. */
.nui-mini-remote .nui-mini-track,
.nui-pod-remote-line { color: var(--accent); }
.nui-pod-remote-line { font-weight: 700; }
.nui-mini-devices,
.nui-mini-here { font-size: 20px; }
.nui-pod-here,
.nui-pod-devbtn { align-self: center; min-height: var(--nui-tap); }
.nui-pod-devnote { padding: 8px 16px 4px; }
body.eink .nui-mini-remote .nui-mini-track,
body.eink .nui-pod-remote-line { color: #000; }
/* e-reader mode on a phone: black on white */
body.eink .nui-pod-row { background: #fff; }
body.eink .nui-pod-row:active { background: #ddd; }

View file

@ -85,9 +85,6 @@ html.dpw.dpw-in-bar #now-playing-bar #nui-pod-mini {
html.dpw.dpw-in-bar #now-playing-bar #nui-pod-mini { position: static; }
html.dpw.dpw-in-bar #now-playing-bar .nui-pod-mini-bar { top: -1px; }
html.dpw.dpw-in-bar #now-playing-bar .nui-pod-art-mini { width: 40px; height: 40px; margin-left: 1.5rem; }
/* The page keeps clear of the bar by its real height (two rows on phones,
the player in it or not). */
html.dpw.dpw-in-bar .main-content,
html.dpw.dpw-in-bar.nui-pod-mini-open .main-content {
padding-bottom: calc(var(--dpw-bar-h, 72px) + 2rem);
}