diff --git a/podcasts/migrations/0012_player_state.py b/podcasts/migrations/0012_player_state.py new file mode 100644 index 0000000..11190cf --- /dev/null +++ b/podcasts/migrations/0012_player_state.py @@ -0,0 +1,51 @@ +# 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')}, + }, + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 57a10af..54c8a38 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -254,3 +254,65 @@ 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}' diff --git a/podcasts/test_player.py b/podcasts/test_player.py new file mode 100644 index 0000000..65ed1bb --- /dev/null +++ b/podcasts/test_player.py @@ -0,0 +1,365 @@ +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') diff --git a/podcasts/urls.py b/podcasts/urls.py index b56d675..72c9f86 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -30,4 +30,8 @@ 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'), ] diff --git a/podcasts/views.py b/podcasts/views.py index e167e70..eb2f932 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -1,9 +1,12 @@ 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 +from datetime import timedelta, timezone as dt_timezone import xml.etree.ElementTree as ET import feedparser @@ -14,7 +17,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 +from django.http import HttpResponse, JsonResponse, StreamingHttpResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -23,7 +26,8 @@ from . import chapters as chapters_mod from . import feedext from . import transcripts as transcripts_mod from .models import ( - EpisodeProgress, EpisodeTranscript, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played, + EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, + PodcastSettings, counts_as_played, ) from .shownotes import extract_timestamps, sanitize_html @@ -1829,3 +1833,323 @@ 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=, + 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 diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index 9f529cf..6ce2a64 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -73,7 +73,6 @@ let state = load('state', null) || empty(); let outbox = load('outbox', []); - let lastRefresh = 0; function save() { store('state', state); @@ -152,9 +151,9 @@ const fresh = await r.json(); if (outbox.length) continue; state = fresh; - lastRefresh = Date.now(); save(); changed(); + followSavedPlace(); return true; } return false; @@ -620,7 +619,7 @@ lastPos = resumeMs(); storePlayer(); if (play && !stop) resume(); - else { wantPlay = false; report(); } + else { wantPlay = false; report(); syncSoon(); } } // A pause rewinds a little on the way back in, to find the thread again. @@ -639,6 +638,7 @@ if (!loaded) prepare(); else catchUp(); wantPlay = true; + sync.start = true; applyKind(); setSession(); const p = audio.play(); @@ -649,9 +649,11 @@ buffering = false; if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen'); report(); + syncSoon(); }); } report(); + syncSoon(); } function pause() { @@ -659,6 +661,7 @@ if (!audio.paused) audio.pause(); pausedAt = pausedAt || Date.now(); report(); + syncSoon(); } function seek(ms) { @@ -671,7 +674,10 @@ 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) { @@ -715,6 +721,7 @@ sleep.queueEnded(); wantPlay = false; report(); + syncSoon(); } // A browser that pauses the element while it loads (power saving) may drop @@ -740,10 +747,12 @@ 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', () => { @@ -763,6 +772,7 @@ 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. @@ -777,7 +787,10 @@ } // The sleep timer counts while either plays (radio and podcasts share the player). sleep.tick(playing || radioPlaying()); - if (playing || sleep.leftMs > 0) report(); + // 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(); }, 1000); // ---- radio and podcasts take turns --------------------------------------------------- @@ -811,6 +824,8 @@ putAway(); const r = playStation.apply(this, arguments); radioChanged(); + sync.start = true; + syncSoon(); return r; }; } @@ -862,9 +877,479 @@ 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; + 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'); @@ -876,7 +1361,8 @@ function playerJson() { const fid = curFeed(); - const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json()}; + const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json(), + remote: remoteJson(), devices: devicesJson()}; if (!cur) { o.kind = null; return o; } o.kind = 'podcast'; o.episodeId = cur.episode__id; @@ -1011,6 +1497,10 @@ }, 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. }; @@ -1130,14 +1620,26 @@ } }; 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() - lastRefresh > 5 * 60000) { + } else if (Date.now() - lastVisible > 15000) { + lastVisible = Date.now(); refresh(false); + fetchPlayer(); + if (!source) listen(); } }); - window.addEventListener('online', () => { flush(); }); + window.addEventListener('online', () => { + flush(); + if (!source) listen(); + }); + + // The shared player: what plays on the other devices, and from now on every change. + startSync(); // The page (copied from the app) comes last, once all of the above is in place. const script = document.createElement('script'); diff --git a/static/js/sw.js b/static/js/sw.js index cd373e2..c19c090 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v57'; +const CACHE = 'diora-v58'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', @@ -65,6 +65,7 @@ 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') || diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index c9e7919..bbade4e 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 6c2b071 2026-10-02: web-native/static/native-podcast.js +// Copied from diora-android 38d03c6 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,7 +173,8 @@ // ---- actions --------------------------------------------------------------------------- function play(e) { - if (currentId() === e.id) { openPlayer(); return; } + const r = remote(); + if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); return; } N.podcastPlayEpisode(JSON.stringify(e.raw), currentId()); } @@ -1302,11 +1303,16 @@ 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', () => N.podcastSkip(false), 'nui-pod-skip'), + button('↺', '15 s zurück', () => (miniKind === 'remote' + ? remoteCmd('skip', -(devicePrefs().skip_back || 15)) : N.podcastSkip(false)), 'nui-pod-skip'), button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'), - button('▶', 'Abspielen', () => (miniKind === 'radio' - ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle')); + devices, here, + button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle() + : 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'); @@ -1323,7 +1329,8 @@ } } - // What the bar shows: 'podcast', 'radio' (a station is chosen) or ''. + // What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote' + // (another device is the active one) or ''. let miniKind = ''; function radioNow() { @@ -1345,16 +1352,32 @@ } function renderMini() { + const r = remote(); const isPod = player.kind === 'podcast'; - document.documentElement.classList.toggle('nui-pod-playing', isPod); + document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r); if (!mini) return; - const radio = isPod ? null : radioNow(); - miniKind = isPod ? 'podcast' : radio ? 'radio' : ''; + const radio = isPod || r ? null : radioNow(); + miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : ''; mini.hidden = !miniKind; - mini.classList.toggle('nui-mini-radio', miniKind === 'radio'); + mini.classList.toggle('nui-mini-radio', miniKind === 'radio' || (!!r && r.kind === 'radio')); + mini.classList.toggle('nui-mini-remote', !!r); document.documentElement.classList.toggle('nui-pod-mini-open', !!miniKind); - mini.querySelector('.nui-pod-skip').hidden = miniKind !== 'podcast'; + mini.querySelector('.nui-pod-skip').hidden = !(isPod || r) || (!!r && r.kind !== '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 …' @@ -1637,6 +1660,7 @@ let sheetKind = ''; function openPlayer() { + if (remote()) { openRemotePlayer(); return; } if (player.kind !== 'podcast') return; closePlayer(); sheetKind = 'podcast'; @@ -1675,7 +1699,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 => { @@ -1683,8 +1707,9 @@ if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); } }), 'nui-pod-fav'), button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'), - button('≡', 'Warteschlange', () => { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); }), - ); + N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'), + button('≡', 'Warteschlange', showQueue), + ].filter(Boolean)); panel.append(extra); const notes = el('div', 'nui-pod-notesbox'); panel.append(notes); @@ -1730,6 +1755,7 @@ 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); @@ -1763,10 +1789,24 @@ renderMini(); if (sheet && sheetKind === 'radio') renderRadioPlayer(); }; - window.__dioraOpenPlayer = kind => (kind === 'radio' ? openRadioPlayer() : openPlayer()); + window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? 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; @@ -1869,6 +1909,171 @@ 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 = ''; + 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 ⋯ diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 57ae8df..2d24ff5 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android e6761d0 2026-10-02: the podcast parts of web-native/static/native.css +/* Copied from diora-android 38d03c6 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. */ @@ -338,6 +338,18 @@ html.nui-radio.nui-pod-mini-open body { .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; }