Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/ (Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung, transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück. Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach 15 s neu geholt. Podcast-Seite neu kopiert, sw v58. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
713b25f36a
commit
3eb6a27ec3
9 changed files with 1553 additions and 27 deletions
51
podcasts/migrations/0012_player_state.py
Normal file
51
podcasts/migrations/0012_player_state.py
Normal file
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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}'
|
||||
|
|
|
|||
365
podcasts/test_player.py
Normal file
365
podcasts/test_player.py
Normal file
|
|
@ -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')
|
||||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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=<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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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 = '<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 ⋯
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue