Compare commits
4 commits
8015f4eecc
...
262dd0d207
| Author | SHA1 | Date | |
|---|---|---|---|
| 262dd0d207 | |||
| 21c314dbd7 | |||
| 9d44515c63 | |||
| 3eb6a27ec3 |
9 changed files with 1556 additions and 30 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,482 @@
|
|||
return r;
|
||||
};
|
||||
}
|
||||
// The radio stopping is news for the shared player too.
|
||||
if (typeof window.stopPlayback === 'function') {
|
||||
const stopPlayback = window.stopPlayback;
|
||||
window.stopPlayback = function () {
|
||||
const was = radioPlaying();
|
||||
const r = stopPlayback.apply(this, arguments);
|
||||
if (was) syncSoon();
|
||||
return r;
|
||||
};
|
||||
}
|
||||
const savedRows = document.getElementById('saved-tbody');
|
||||
if (savedRows) new MutationObserver(radioChanged).observe(savedRows, {childList: true});
|
||||
|
||||
// Player sync: the station another device played last becomes the current
|
||||
// one here, without playing (the bar shows it with ▶).
|
||||
window.__dioraAdoptStation = function (st) {
|
||||
if (!st || !st.url || radioPlaying() || typeof currentStation === 'undefined') return;
|
||||
currentStation = {url: st.url, name: st.name || '', id: st.id || null};
|
||||
currentTrack = '';
|
||||
const name = document.getElementById('now-playing-station');
|
||||
if (name) name.textContent = currentStation.name;
|
||||
const track = document.getElementById('now-playing-track');
|
||||
if (track) track.textContent = '';
|
||||
const b = document.getElementById('play-stop-btn');
|
||||
if (b) {
|
||||
b.style.display = '';
|
||||
b.textContent = '▶ Play';
|
||||
b.classList.remove('playing');
|
||||
}
|
||||
radioChanged();
|
||||
};
|
||||
|
||||
// ---- player sync: one player for all devices (like Spotify Connect) ------------------
|
||||
// The server keeps what plays (/podcasts/player/, see podcasts/views.py). The
|
||||
// device that starts playing becomes the active one and stays it while it is
|
||||
// online, also paused. Every other device stops its own playback and shows
|
||||
// the active one's as "remote" (playerJson): its buttons send commands
|
||||
// there, "Hier abspielen" plays it here from its place, the device sheet
|
||||
// moves it anywhere (transfer). When the active device is gone, what it
|
||||
// played last is loaded here, paused. In the app PlayerSync.kt does this.
|
||||
|
||||
const REPORT_EVERY_MS = 10000;
|
||||
const OLD_COMMAND_MS = 60000; // a command this old (the page was away) is only acknowledged
|
||||
|
||||
function deviceId() {
|
||||
let id = '';
|
||||
try { id = localStorage.getItem('diora_device_id') || ''; } catch (e) {}
|
||||
if (!id) {
|
||||
id = 'web-' + Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
|
||||
try { localStorage.setItem('diora_device_id', id); } catch (e) {}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function browserName() {
|
||||
const ua = navigator.userAgent;
|
||||
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web';
|
||||
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows'
|
||||
: /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';
|
||||
return os ? browser + '/' + os : browser;
|
||||
}
|
||||
|
||||
const DEVICE_ID = deviceId();
|
||||
const DEVICE_NAME = 'Browser (' + browserName() + ')';
|
||||
|
||||
const sync = {
|
||||
state: null, // the server's state, as last taken in
|
||||
heardAt: 0, // when it came (this clock only measures the time since)
|
||||
later: null, // the newest state that came while a report of ours was on its way
|
||||
ownAt: load('sync_at', ''), // server time of our last report: what the others did before is old
|
||||
sending: 0, // reports on their way
|
||||
timer: 0, // a report about to go
|
||||
lastSent: 0,
|
||||
quiet: false, // playback changes the sync makes itself: no reports of their own
|
||||
doneCmd: null, // the command the next report says is done
|
||||
start: false, // playback was just started here (by the user, a command): we take over
|
||||
executed: new Set(),
|
||||
hint: null, // what a command of ours will change, shown until the device reports
|
||||
};
|
||||
|
||||
const busy = () => sync.sending > 0 || !!sync.timer;
|
||||
|
||||
function isoMs(iso) {
|
||||
const t = Date.parse(iso || '');
|
||||
return isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
// The server's clock now: its time when the state came plus what has passed since.
|
||||
function serverNow(st) {
|
||||
return isoMs(st.server_time) + (Date.now() - sync.heardAt);
|
||||
}
|
||||
|
||||
// Where the state's item is now: while it plays, its last place plus the time since.
|
||||
function placeOf(st) {
|
||||
let pos = st.position_ms || 0;
|
||||
if (st.playing && st.active_online && st.updated_at) {
|
||||
pos += Math.max(0, serverNow(st) - isoMs(st.updated_at)) * (st.speed || 1);
|
||||
}
|
||||
const dur = st.episode ? (st.episode.duration_seconds || 0) * 1000 : 0;
|
||||
return Math.round(dur > 0 ? Math.min(pos, dur) : pos);
|
||||
}
|
||||
|
||||
// Another device is the active one, it is online and has something.
|
||||
function otherActive(st) {
|
||||
return !!(st && st.device_id && st.device_id !== DEVICE_ID && st.active_online &&
|
||||
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
|
||||
}
|
||||
|
||||
// The page's player.remote: what the other device plays.
|
||||
function remoteJson() {
|
||||
const st = sync.state;
|
||||
if (!otherActive(st)) return null;
|
||||
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
|
||||
const radio = st.kind === 'radio';
|
||||
if (radio) {
|
||||
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
|
||||
o.title = o.station.name;
|
||||
} else {
|
||||
const e = st.episode;
|
||||
o.episodeId = e.id;
|
||||
o.feedId = e.feed__id || 0;
|
||||
o.title = plain(e.title);
|
||||
o.feed = plain(e.feed__title);
|
||||
o.artwork = e.artwork_url || e.feed__artwork_url || '';
|
||||
o.dur = (e.duration_seconds || 0) * 1000;
|
||||
o.pos = placeOf(st);
|
||||
}
|
||||
// A command of ours shows at once, until the device reports back.
|
||||
const h = sync.hint;
|
||||
if (h && h.updatedAt === st.updated_at && Date.now() < h.until) {
|
||||
o.playing = h.playing;
|
||||
if (!radio) {
|
||||
o.pos = Math.round(h.pos + (o.playing ? (Date.now() - h.at) * (st.speed || 1) : 0));
|
||||
o.pos = Math.max(0, o.dur > 0 ? Math.min(o.pos, o.dur) : o.pos);
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// The page's player.devices: the server's list, with this browser marked (and in it).
|
||||
function devicesJson() {
|
||||
const st = sync.state;
|
||||
const list = ((st && st.devices) || []).map(d =>
|
||||
(d.id === DEVICE_ID ? Object.assign({}, d, {me: true, online: true}) : Object.assign({}, d)));
|
||||
if (!list.some(d => d.me)) {
|
||||
list.unshift({id: DEVICE_ID, name: DEVICE_NAME, online: true, active: !!st && st.device_id === DEVICE_ID, me: true});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// What this device plays, as a report has it.
|
||||
function mine() {
|
||||
const st = typeof currentStation !== 'undefined' ? currentStation : null;
|
||||
if (st && (radioPlaying() || !cur)) {
|
||||
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
|
||||
position_ms: 0, playing: radioPlaying()};
|
||||
}
|
||||
if (cur) {
|
||||
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
|
||||
playing: wantPlay, speed: speedFor(curFeed())};
|
||||
}
|
||||
return {kind: '', position_ms: 0, playing: false};
|
||||
}
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// This device plays: it is the active one now (the server's answer will say so too).
|
||||
function claim() {
|
||||
const st = sync.state;
|
||||
if (st && st.device_id !== DEVICE_ID) {
|
||||
sync.state = Object.assign({}, st, {device_id: DEVICE_ID, device_name: DEVICE_NAME, playing: true, command: null});
|
||||
}
|
||||
sync.hint = null;
|
||||
}
|
||||
|
||||
// Something changed here: tell the server, a moment later so that a burst
|
||||
// (switching stations, several skips) goes as one report.
|
||||
function syncSoon() {
|
||||
if (sync.quiet) return;
|
||||
if (sync.start && mine().playing) claim();
|
||||
if (!sync.timer) sync.timer = setTimeout(syncReport, 300);
|
||||
}
|
||||
|
||||
function syncReport() {
|
||||
clearTimeout(sync.timer);
|
||||
sync.timer = 0;
|
||||
const body = Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, mine());
|
||||
const done = sync.doneCmd;
|
||||
if (done) body.done_command = done;
|
||||
// Only a start makes us the active device; a late 10 s report after
|
||||
// another device took over doesn't take it back.
|
||||
if (body.playing && sync.start) {
|
||||
body.start = true;
|
||||
claim();
|
||||
}
|
||||
sync.start = false;
|
||||
sync.lastSent = Date.now();
|
||||
sync.sending++;
|
||||
return post('/podcasts/player/report/', body)
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
.then(st => {
|
||||
sync.sending--;
|
||||
if (st) {
|
||||
if (done && sync.doneCmd === done) sync.doneCmd = null;
|
||||
// Only a report the server took is our last change (an ignored one isn't).
|
||||
if (st.device_id === DEVICE_ID) {
|
||||
sync.ownAt = st.server_time;
|
||||
store('sync_at', sync.ownAt);
|
||||
}
|
||||
}
|
||||
heard(st);
|
||||
return st;
|
||||
});
|
||||
}
|
||||
|
||||
// A state from the server (the stream, or the answer to a report or command).
|
||||
// While a report of ours is on its way, states wait for its answer: they may
|
||||
// be from before it, and would stop what we just started.
|
||||
function heard(st) {
|
||||
if (st && typeof st.seq === 'number' && (!sync.later || st.seq >= sync.later.seq)) sync.later = st;
|
||||
if (busy() || !sync.later) return;
|
||||
st = sync.later;
|
||||
sync.later = null;
|
||||
if (sync.state && st.seq < sync.state.seq) return;
|
||||
sync.state = st;
|
||||
sync.heardAt = Date.now();
|
||||
act(st);
|
||||
report();
|
||||
}
|
||||
|
||||
// What a state means for this device.
|
||||
function act(st) {
|
||||
const cmd = st.command;
|
||||
if (cmd && cmd.id && !sync.executed.has(cmd.id) && (cmd.to_device_id || st.device_id) === DEVICE_ID) {
|
||||
sync.executed.add(cmd.id);
|
||||
if (serverNow(st) - isoMs(cmd.at) > OLD_COMMAND_MS) {
|
||||
sync.doneCmd = cmd.id;
|
||||
syncReport();
|
||||
} else {
|
||||
execute(cmd, st);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (st.device_id === DEVICE_ID) {
|
||||
// The server thinks we play, but we don't (this page was reloaded): say so.
|
||||
if (st.playing && !mine().playing) syncSoon();
|
||||
return;
|
||||
}
|
||||
if (!st.device_id) return;
|
||||
const newer = isoMs(st.updated_at) > isoMs(sync.ownAt);
|
||||
if (st.active_online) {
|
||||
if (st.playing) handOver(st);
|
||||
// Paused there on the episode that waits here too: ours goes to its place.
|
||||
else if (newer && st.kind === 'podcast' && st.episode) movePausedTo(st.episode.id, st.position_ms);
|
||||
return;
|
||||
}
|
||||
// The active device is gone: what it had last comes here, if it's newer than our own last change.
|
||||
if (newer) adopt(st);
|
||||
}
|
||||
|
||||
// A command for this device: carry it out, then one report that says it's done.
|
||||
function execute(cmd, st) {
|
||||
const kind = mine().kind;
|
||||
const a = cmd.action;
|
||||
sync.doneCmd = cmd.id;
|
||||
sync.quiet = true;
|
||||
try {
|
||||
if (a === 'transfer') {
|
||||
takeOver(st);
|
||||
} else if (kind === 'radio') {
|
||||
if (a === 'play' && !radioPlaying()) togglePlayStop();
|
||||
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
|
||||
} else if (kind === 'podcast') {
|
||||
if (a === 'play') resume();
|
||||
else if (a === 'pause' || a === 'stop') pause();
|
||||
else if (a === 'seek') seek(Number(cmd.value) || 0);
|
||||
else if (a === 'skip') {
|
||||
const to = posMs() + (Number(cmd.value) || 0) * 1000;
|
||||
const dur = durMs();
|
||||
seek(dur > 0 ? Math.min(to, dur - 1000) : to);
|
||||
} else if (a === 'next') next();
|
||||
}
|
||||
} finally {
|
||||
sync.quiet = false;
|
||||
}
|
||||
syncReport();
|
||||
}
|
||||
|
||||
// Another device started playing: this one stops (the episode keeps its place).
|
||||
function handOver(st) {
|
||||
if (!wantPlay && !radioPlaying()) return;
|
||||
sync.quiet = true;
|
||||
try {
|
||||
if (wantPlay) pause();
|
||||
if (radioPlaying()) stopPlayback(false);
|
||||
} finally {
|
||||
sync.quiet = false;
|
||||
}
|
||||
toast('Läuft jetzt auf ' + (st.device_name || 'einem anderen Gerät'));
|
||||
}
|
||||
|
||||
// Load an episode paused at a place (adopting, or before playing it here).
|
||||
function loadAt(ep, at) {
|
||||
at = Math.max(0, Math.round(at || 0));
|
||||
if (curId() === ep.id) {
|
||||
if (loaded && audio.readyState > 0) {
|
||||
try { audio.currentTime = at / 1000; } catch (e) {}
|
||||
} else {
|
||||
startAt = at;
|
||||
}
|
||||
} else {
|
||||
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
|
||||
unload();
|
||||
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
|
||||
startAt = at;
|
||||
wantPlay = false;
|
||||
buffering = false;
|
||||
error = null;
|
||||
lastDur = 0;
|
||||
storePlayer();
|
||||
}
|
||||
mustReach = null;
|
||||
pausedAt = 0;
|
||||
lastPos = at;
|
||||
cur.position_seconds = Math.floor(at / 1000);
|
||||
forEpisode(state, ep.id, o => { o.position_seconds = Math.floor(at / 1000); });
|
||||
save();
|
||||
report();
|
||||
}
|
||||
|
||||
// The episode that waits here, paused, follows a newer place from elsewhere
|
||||
// (another device played on): never while it plays here, never over a place
|
||||
// of ours that isn't saved yet, not for a few seconds' difference.
|
||||
function movePausedTo(id, at) {
|
||||
if (curId() !== id || wantPlay) return false;
|
||||
if (outbox.some(op => op.key === 'progress:' + id)) return false;
|
||||
if (Math.abs(at - posMs()) <= 5000) return false;
|
||||
loadAt({id}, at);
|
||||
return true;
|
||||
}
|
||||
|
||||
// After fetching the podcast state: the saved place of that episode may be newer.
|
||||
function followSavedPlace() {
|
||||
if (!cur) return;
|
||||
let secs = null;
|
||||
forEpisode(state, cur.episode__id, o => { if (secs == null && o.position_seconds != null) secs = o.position_seconds; });
|
||||
if (secs == null) return;
|
||||
// Where resuming would start (resumeMs): from 5 s on, past the podcast's intro.
|
||||
movePausedTo(cur.episode__id, Math.max(secs > 5 ? secs * 1000 : 0, introMs(curFeed())));
|
||||
}
|
||||
|
||||
// Play what the shared player has, here, from its place ("Hier abspielen", a transfer).
|
||||
function takeOver(st) {
|
||||
st = st || sync.state;
|
||||
if (st && st.kind === 'radio' && st.station) {
|
||||
playStation(st.station.url, st.station.name || '', st.station.id || null);
|
||||
} else if (st && st.kind === 'podcast' && st.episode) {
|
||||
loadAt(st.episode, placeOf(st));
|
||||
resume();
|
||||
} else if (cur) {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
|
||||
// The active device is gone and we play nothing: its item becomes ours, paused.
|
||||
function adopt(st) {
|
||||
if (wantPlay || radioPlaying()) return;
|
||||
if (st.kind === 'podcast' && st.episode) {
|
||||
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
|
||||
else loadAt(st.episode, st.position_ms);
|
||||
} else if (st.kind === 'radio' && st.station) {
|
||||
const now = typeof currentStation !== 'undefined' ? currentStation : null;
|
||||
if (cur || !now || now.url !== st.station.url) {
|
||||
sync.quiet = true;
|
||||
try { putAway(); } finally { sync.quiet = false; }
|
||||
window.__dioraAdoptStation(st.station);
|
||||
}
|
||||
}
|
||||
sync.ownAt = st.server_time;
|
||||
store('sync_at', sync.ownAt);
|
||||
}
|
||||
|
||||
// What a command of ours will do shows at once (the device reports within a second or so).
|
||||
function hintFor(action, value) {
|
||||
const st = sync.state;
|
||||
const r = remoteJson();
|
||||
if (!r) return;
|
||||
const h = {updatedAt: st.updated_at, until: Date.now() + 5000, at: Date.now(), pos: r.pos, playing: r.playing};
|
||||
if (action === 'play') h.playing = true;
|
||||
else if (action === 'pause' || action === 'stop') h.playing = false;
|
||||
else if (action === 'seek') h.pos = value;
|
||||
else if (action === 'skip') h.pos = Math.max(0, r.pos + value * 1000);
|
||||
else return;
|
||||
sync.hint = h;
|
||||
report();
|
||||
}
|
||||
|
||||
// Remote control: a command for the active device (or `to`).
|
||||
function command(action, value, to) {
|
||||
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
|
||||
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
|
||||
if (to) body.to_device_id = to;
|
||||
else hintFor(action, body.value);
|
||||
return post('/podcasts/player/command/', body)
|
||||
.then(r => {
|
||||
if (r.ok) return r.json();
|
||||
return r.json().catch(() => ({})).then(d => {
|
||||
toast(d && d.error === 'nothing to transfer' ? 'Hier läuft nichts, das sich verschieben ließe'
|
||||
: 'Das Gerät ist gerade nicht erreichbar');
|
||||
return null;
|
||||
});
|
||||
})
|
||||
.catch(() => { toast('Keine Verbindung zum Server'); return null; })
|
||||
.then(st => {
|
||||
if (!st) { sync.hint = null; report(); }
|
||||
heard(st);
|
||||
});
|
||||
}
|
||||
|
||||
// Move the playback to a device: here (take over), or there (it plays from the place, we stop).
|
||||
function transfer(id) {
|
||||
if (id === DEVICE_ID) { takeOver(); return; }
|
||||
// What we have isn't shared yet (we're the active device, or there is none): first say what it is.
|
||||
(otherActive(sync.state) ? Promise.resolve() : syncReport()).then(() => command('transfer', 0, id));
|
||||
}
|
||||
|
||||
// The stream: every change, as it happens. The server ends it every few minutes
|
||||
// (and the network may drop it): then again, with the last seq we know.
|
||||
let source = null;
|
||||
let retryMs = 1000;
|
||||
let retryTimer = 0;
|
||||
|
||||
function listen() {
|
||||
clearTimeout(retryTimer);
|
||||
if (source) source.close();
|
||||
const seen = Math.max(sync.state ? sync.state.seq : -1, sync.later ? sync.later.seq : -1);
|
||||
const q = new URLSearchParams({since: String(seen), device_id: DEVICE_ID, device_name: DEVICE_NAME, device_kind: 'web'});
|
||||
source = new EventSource('/podcasts/player/stream/?' + q);
|
||||
source.onopen = () => { retryMs = 1000; };
|
||||
source.onmessage = e => heard(parse(e.data, null));
|
||||
source.onerror = () => {
|
||||
if (source) source.close();
|
||||
source = null;
|
||||
retryTimer = setTimeout(listen, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
function fetchPlayer() {
|
||||
return fetch('/podcasts/player/', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
.then(heard);
|
||||
}
|
||||
|
||||
function startSync() {
|
||||
fetchPlayer().then(listen);
|
||||
}
|
||||
|
||||
// Leaving while playing: the others shouldn't show us as playing until we count as gone.
|
||||
function syncLeaving() {
|
||||
const m = mine();
|
||||
if (!m.playing || !sync.state || sync.state.device_id !== DEVICE_ID || !navigator.sendBeacon) return;
|
||||
m.playing = false;
|
||||
navigator.sendBeacon('/podcasts/player/report/', JSON.stringify(Object.assign({device_id: DEVICE_ID, device_name: DEVICE_NAME}, m)));
|
||||
}
|
||||
|
||||
// ---- what the page and the lock screen see ---------------------------------------------
|
||||
|
||||
const decoder = document.createElement('textarea');
|
||||
|
|
@ -876,7 +1364,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 +1500,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 +1623,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-v58';
|
||||
const CACHE = 'diora-v60';
|
||||
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 57fc424 2026-10-02: the podcast parts of web-native/static/native.css
|
||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||
The web's own additions are in podcasts-web.css. */
|
||||
|
||||
|
|
@ -132,9 +132,6 @@ html.nui-one-player #nui-mini { display: none !important; }
|
|||
.nui-radio-live { text-align: center; font-size: 13px; color: var(--nui-muted); letter-spacing: 0.04em; }
|
||||
.nui-radio-live.on { color: var(--accent); font-weight: 700; }
|
||||
.nui-radio-stations { align-self: center; }
|
||||
html.nui-radio.nui-pod-mini-open body {
|
||||
padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
#nui-pod-bar {
|
||||
display: flex;
|
||||
|
|
@ -338,6 +335,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