Sleep-Timer: eigene Zeit, Nachtfenster, auch fürs Radio, von anderen Geräten setzen; sw v70
- Player-Sync: Aktion 'sleep' (Minuten, -n Folgen, 0 aus); Reports schicken
sleep {left_ms, episodes} mit, der State zeigt ihn (Migration 0016)
- Browser-Host: Nachtfenster sleep_from/sleep_to, Automatik auch beim
Radiostart (auch in diora desktop), Timer des anderen Geräts zählt mit,
ein Timer, der stundenlang nicht lief, verfällt
- Eingabefenster (customPrompt) liegt über Player und Menüs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
985e635f9a
commit
f12451f30e
8 changed files with 298 additions and 43 deletions
23
podcasts/migrations/0016_playerstate_sleep.py
Normal file
23
podcasts/migrations/0016_playerstate_sleep.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.1 on 2026-10-02 22:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('podcasts', '0015_episodeprogress_discarded_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playerstate',
|
||||||
|
name='sleep_episodes',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playerstate',
|
||||||
|
name='sleep_left_ms',
|
||||||
|
field=models.PositiveBigIntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -347,6 +347,10 @@ class PlayerState(models.Model):
|
||||||
# The ids of the devices online at the last change, sorted: when the set
|
# 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.
|
# differs, seq goes up so every device sees others come and go.
|
||||||
online_devices = models.JSONField(default=list, blank=True)
|
online_devices = models.JSONField(default=list, blank=True)
|
||||||
|
# The active device's sleep timer at updated_at: playing time left (counts
|
||||||
|
# down while it plays), or episodes left; both 0: off.
|
||||||
|
sleep_left_ms = models.PositiveBigIntegerField(default=0)
|
||||||
|
sleep_episodes = models.PositiveSmallIntegerField(default=0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_user(cls, user):
|
def for_user(cls, user):
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,34 @@ class PlayerSyncTests(TestCase):
|
||||||
with self.subTest(body=body):
|
with self.subTest(body=body):
|
||||||
self.assertEqual(self.post('command', body).status_code, 400)
|
self.assertEqual(self.post('command', body).status_code, 400)
|
||||||
|
|
||||||
|
def test_sleep_timer_travels_with_the_state(self):
|
||||||
|
self.report('phone', True, kind='radio', start=True, sleep={'left_ms': 900_000, 'episodes': 0})
|
||||||
|
d = self.client.get('/podcasts/player/').json()
|
||||||
|
self.assertEqual(d['sleep'], {'left_ms': 900_000, 'episodes': 0})
|
||||||
|
# Episodes left; nonsense is none; an older client without it: none.
|
||||||
|
self.report('phone', True, sleep={'left_ms': 0, 'episodes': 2})
|
||||||
|
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 2))
|
||||||
|
self.report('phone', True, sleep={'left_ms': 'x', 'episodes': -4})
|
||||||
|
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 0))
|
||||||
|
self.report('phone', True, sleep={'left_ms': 10 ** 12})
|
||||||
|
self.assertEqual(self.state().sleep_left_ms, views.SLEEP_MAX_MINUTES * 60_000)
|
||||||
|
self.report('phone', True)
|
||||||
|
self.assertEqual(self.state().sleep_left_ms, 0)
|
||||||
|
# Another device's ignored report doesn't change it.
|
||||||
|
self.report('phone', True, sleep={'left_ms': 60_000})
|
||||||
|
self.report('web', True, sleep={'left_ms': 5})
|
||||||
|
self.assertEqual(self.state().sleep_left_ms, 60_000)
|
||||||
|
|
||||||
|
def test_sleep_command(self):
|
||||||
|
self.report('phone', True, kind='radio', start=True)
|
||||||
|
r = self.command('web', 'sleep', 30)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual((r.json()['command']['action'], r.json()['command']['value']), ('sleep', 30))
|
||||||
|
self.assertEqual(self.command('web', 'sleep', -1).json()['command']['value'], -1)
|
||||||
|
self.assertEqual(self.command('web', 'sleep', 0).json()['command']['value'], 0)
|
||||||
|
for bad in (None, 'x', 601, -100):
|
||||||
|
self.assertEqual(self.command('web', 'sleep', bad).status_code, 400, bad)
|
||||||
|
|
||||||
def test_transfer_to_another_device(self):
|
def test_transfer_to_another_device(self):
|
||||||
self.report('a', True, pos=1000)
|
self.report('a', True, pos=1000)
|
||||||
self.report('c', False, pos=0) # c is online (paused report: ignored)
|
self.report('c', False, pos=0) # c is online (paused report: ignored)
|
||||||
|
|
|
||||||
|
|
@ -2127,7 +2127,9 @@ def clear_import_downloads(request):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
|
PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
|
||||||
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer')
|
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer', 'sleep')
|
||||||
|
SLEEP_MAX_MINUTES = 600 # a sleep timer: 1 min to 10 h, or after 1 to SLEEP_MAX_EPISODES
|
||||||
|
SLEEP_MAX_EPISODES = 99
|
||||||
PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
|
PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
|
||||||
PLAYER_DEVICES_DAYS = 7
|
PLAYER_DEVICES_DAYS = 7
|
||||||
STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
|
STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
|
||||||
|
|
@ -2237,6 +2239,7 @@ def _player_json(user, p, now=None):
|
||||||
'updated_at': _iso_ms(p.updated_at),
|
'updated_at': _iso_ms(p.updated_at),
|
||||||
'server_time': _iso_ms(now),
|
'server_time': _iso_ms(now),
|
||||||
'command': p.command,
|
'command': p.command,
|
||||||
|
'sleep': {'left_ms': p.sleep_left_ms, 'episodes': p.sleep_episodes},
|
||||||
'devices': devices,
|
'devices': devices,
|
||||||
'active_online': _is_online(user, p.device_id, now),
|
'active_online': _is_online(user, p.device_id, now),
|
||||||
}
|
}
|
||||||
|
|
@ -2311,6 +2314,13 @@ def player_report(request):
|
||||||
station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
|
station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
|
||||||
int(sid) if _number(sid) and sid == int(sid) else None)
|
int(sid) if _number(sid) and sid == int(sid) else None)
|
||||||
|
|
||||||
|
# The sleep timer, if the device has one (older clients don't say: none).
|
||||||
|
sleep = body.get('sleep') if isinstance(body.get('sleep'), dict) else {}
|
||||||
|
sleep_left = sleep.get('left_ms', 0)
|
||||||
|
sleep_left = min(int(sleep_left), SLEEP_MAX_MINUTES * 60_000) if _finite(sleep_left) and sleep_left > 0 else 0
|
||||||
|
sleep_episodes = sleep.get('episodes', 0)
|
||||||
|
sleep_episodes = min(int(sleep_episodes), SLEEP_MAX_EPISODES) if _finite(sleep_episodes) and sleep_episodes > 0 else 0
|
||||||
|
|
||||||
device_id = body['device_id']
|
device_id = body['device_id']
|
||||||
done = body.get('done_command')
|
done = body.get('done_command')
|
||||||
start = body.get('start') is True
|
start = body.get('start') is True
|
||||||
|
|
@ -2327,6 +2337,8 @@ def player_report(request):
|
||||||
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
|
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
|
||||||
playing=playing and bool(kind),
|
playing=playing and bool(kind),
|
||||||
speed=float(speed),
|
speed=float(speed),
|
||||||
|
sleep_left_ms=sleep_left,
|
||||||
|
sleep_episodes=sleep_episodes,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
device_name=body['device_name'],
|
device_name=body['device_name'],
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
|
|
@ -2348,7 +2360,8 @@ def player_command(request):
|
||||||
"""Remote control: a command for a device (to_device_id, else the active
|
"""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
|
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
|
device that is online. transfer: the device plays the shared item from
|
||||||
its place and so becomes the active one."""
|
its place and so becomes the active one. sleep: its sleep timer, value =
|
||||||
|
minutes of playing time, -n = after n episodes, 0 = off."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
body, error = _player_body(request)
|
body, error = _player_body(request)
|
||||||
|
|
@ -2363,6 +2376,12 @@ def player_command(request):
|
||||||
if not _finite(value):
|
if not _finite(value):
|
||||||
return JsonResponse({'error': 'value must be a number'}, status=400)
|
return JsonResponse({'error': 'value must be a number'}, status=400)
|
||||||
value = max(int(value), 0) if action == 'seek' else int(value)
|
value = max(int(value), 0) if action == 'seek' else int(value)
|
||||||
|
elif action == 'sleep':
|
||||||
|
value = body.get('value')
|
||||||
|
if not _finite(value) or not -SLEEP_MAX_EPISODES <= int(value) <= SLEEP_MAX_MINUTES:
|
||||||
|
return JsonResponse({'error': f'value must be minutes (up to {SLEEP_MAX_MINUTES}), '
|
||||||
|
f'-episodes or 0'}, status=400)
|
||||||
|
value = int(value)
|
||||||
to = body.get('to_device_id')
|
to = body.get('to_device_id')
|
||||||
if to is not None and (not isinstance(to, str) or len(to) > 64):
|
if to is not None and (not isinstance(to, str) or len(to) > 64):
|
||||||
return JsonResponse({'error': 'invalid to_device_id'}, status=400)
|
return JsonResponse({'error': 'invalid to_device_id'}, status=400)
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
smart_played: [0, 15, 30, 45, 60, 120, 300, 600],
|
smart_played: [0, 15, 30, 45, 60, 120, 300, 600],
|
||||||
};
|
};
|
||||||
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
|
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
|
||||||
sleep_last: 15, sleep_auto: false}, load('prefs', {}));
|
sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6}, load('prefs', {}));
|
||||||
|
|
||||||
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
|
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
|
||||||
function eff(name) {
|
function eff(name) {
|
||||||
|
|
@ -449,6 +449,9 @@
|
||||||
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
|
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
|
||||||
} else if (name === 'remove_played') {
|
} else if (name === 'remove_played') {
|
||||||
prefs.remove_played = !!value;
|
prefs.remove_played = !!value;
|
||||||
|
} else if (name === 'sleep_from' || name === 'sleep_to') {
|
||||||
|
if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return;
|
||||||
|
prefs[name] = value;
|
||||||
} else {
|
} else {
|
||||||
return; // skip_silence, headset_next: nothing a browser can do
|
return; // skip_silence, headset_next: nothing a browser can do
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +461,8 @@
|
||||||
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
|
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
|
||||||
function prefsJson() {
|
function prefsJson() {
|
||||||
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
|
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
|
||||||
remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}};
|
remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {},
|
||||||
|
sleep_from: sleepHour('sleep_from', 22), sleep_to: sleepHour('sleep_to', 6)};
|
||||||
}
|
}
|
||||||
|
|
||||||
function speedFor(feedId) {
|
function speedFor(feedId) {
|
||||||
|
|
@ -482,32 +486,57 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
|
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
|
||||||
|
// For episodes and the radio. A timer that hasn't counted for hours (set, then
|
||||||
|
// nothing played) is last night's: it's dropped (SleepRules.forgotten).
|
||||||
|
|
||||||
const FADE_MS = 10000;
|
const FADE_MS = 10000;
|
||||||
const MAX_GAP_MS = 10000;
|
const MAX_GAP_MS = 10000;
|
||||||
|
const IDLE_FORGET_MS = 3 * 3600000;
|
||||||
|
|
||||||
|
function sleepHour(name, fallback) {
|
||||||
|
const v = prefs[name];
|
||||||
|
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [from, to) may run over midnight; from == to: all day (SleepRules.inWindow).
|
||||||
|
function inSleepWindow(hour, from, to) {
|
||||||
|
if (from === to) return true;
|
||||||
|
return from < to ? hour >= from && hour < to : hour >= from || hour < to;
|
||||||
|
}
|
||||||
|
|
||||||
const sleep = {
|
const sleep = {
|
||||||
leftMs: 0,
|
leftMs: 0,
|
||||||
episodes: 0,
|
episodes: 0,
|
||||||
lastTick: 0,
|
lastTick: 0,
|
||||||
|
lastCounted: 0,
|
||||||
get active() { return this.leftMs > 0 || this.episodes > 0; },
|
get active() { return this.leftMs > 0 || this.episodes > 0; },
|
||||||
set(minutes, episodes) {
|
set(minutes, episodes) {
|
||||||
this.leftMs = minutes * 60000;
|
this.leftMs = minutes * 60000;
|
||||||
this.episodes = minutes > 0 ? 0 : episodes;
|
this.episodes = minutes > 0 ? 0 : episodes;
|
||||||
this.lastTick = 0;
|
this.lastTick = 0;
|
||||||
|
this.lastCounted = this.active ? Date.now() : 0;
|
||||||
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
|
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
|
||||||
setFade(1);
|
setFade(1);
|
||||||
|
sleepChanged();
|
||||||
},
|
},
|
||||||
extend(minutes) {
|
extend(minutes) {
|
||||||
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
|
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
|
||||||
this.leftMs += minutes * 60000;
|
this.leftMs += minutes * 60000;
|
||||||
|
this.lastCounted = Date.now();
|
||||||
setFade(1);
|
setFade(1);
|
||||||
|
sleepChanged();
|
||||||
},
|
},
|
||||||
tick(playing) {
|
tick(playing) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const delta = this.lastTick ? now - this.lastTick : 0;
|
const delta = this.lastTick ? now - this.lastTick : 0;
|
||||||
this.lastTick = now;
|
this.lastTick = now;
|
||||||
if (this.leftMs <= 0 || !playing) return;
|
if (!this.active) return;
|
||||||
|
if (!playing) {
|
||||||
|
if (this.lastCounted && now - this.lastCounted > IDLE_FORGET_MS) this.set(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastCounted = now;
|
||||||
|
if (this.leftMs <= 0) return;
|
||||||
// A gap means nothing was ticking (paused, tab asleep): not playing time.
|
// A gap means nothing was ticking (paused, tab asleep): not playing time.
|
||||||
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
|
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
|
||||||
if (this.leftMs <= 0) {
|
if (this.leftMs <= 0) {
|
||||||
|
|
@ -515,6 +544,7 @@
|
||||||
setFade(1);
|
setFade(1);
|
||||||
pause();
|
pause();
|
||||||
stopRadio(); // the radio sleeps too (it plays in app.js's element)
|
stopRadio(); // the radio sleeps too (it plays in app.js's element)
|
||||||
|
sleepChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
|
if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
|
||||||
|
|
@ -523,20 +553,32 @@
|
||||||
episodeFinished() {
|
episodeFinished() {
|
||||||
if (this.episodes <= 0) return false;
|
if (this.episodes <= 0) return false;
|
||||||
this.episodes--;
|
this.episodes--;
|
||||||
|
if (this.episodes === 0) sleepChanged();
|
||||||
return this.episodes === 0;
|
return this.episodes === 0;
|
||||||
},
|
},
|
||||||
queueEnded() { this.episodes = 0; },
|
queueEnded() {
|
||||||
// An episode starts playing: at night, the timer may switch itself on.
|
if (!this.episodes) return;
|
||||||
|
this.episodes = 0;
|
||||||
|
sleepChanged();
|
||||||
|
},
|
||||||
|
// Playback starts (an episode or the radio): in the night window, the timer may switch itself on.
|
||||||
maybeAutoEnable() {
|
maybeAutoEnable() {
|
||||||
if (this.active || !prefs.sleep_auto) return;
|
if (this.active || !prefs.sleep_auto) return;
|
||||||
const hour = new Date().getHours();
|
if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 6))) {
|
||||||
if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
|
this.set(prefs.sleep_last || 15, 0);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
json() {
|
json() {
|
||||||
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15};
|
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto,
|
||||||
|
last: prefs.sleep_last || 15, from: sleepHour('sleep_from', 22), to: sleepHour('sleep_to', 6)};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set, changed or done here: the other devices show it (when this one is the active one).
|
||||||
|
function sleepChanged() {
|
||||||
|
if (typeof sync !== 'undefined' && sync.state && sync.state.device_id === DEVICE_ID) syncSoon();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the player (RadioService.EpisodeTracker) ---------------------------------------
|
// ---- the player (RadioService.EpisodeTracker) ---------------------------------------
|
||||||
|
|
||||||
const audio = new Audio(); // the radio keeps app.js's own element
|
const audio = new Audio(); // the radio keeps app.js's own element
|
||||||
|
|
@ -920,6 +962,7 @@
|
||||||
window.playStation = function () {
|
window.playStation = function () {
|
||||||
putAway();
|
putAway();
|
||||||
const r = playStation.apply(this, arguments);
|
const r = playStation.apply(this, arguments);
|
||||||
|
sleep.maybeAutoEnable();
|
||||||
radioChanged();
|
radioChanged();
|
||||||
sync.start = true;
|
sync.start = true;
|
||||||
syncSoon();
|
syncSoon();
|
||||||
|
|
@ -1087,11 +1130,22 @@
|
||||||
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
|
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The other device's sleep timer now: its minutes count down while it plays (real time).
|
||||||
|
function remoteSleep(st) {
|
||||||
|
const z = st.sleep || {};
|
||||||
|
let left = z.left_ms || 0;
|
||||||
|
if (left > 0 && st.playing && st.active_online && st.updated_at) {
|
||||||
|
left = Math.max(0, left - Math.max(0, serverNow(st) - isoMs(st.updated_at)));
|
||||||
|
}
|
||||||
|
return {leftMs: Math.round(left), episodes: z.episodes || 0};
|
||||||
|
}
|
||||||
|
|
||||||
// The page's player.remote: what the other device plays.
|
// The page's player.remote: what the other device plays.
|
||||||
function remoteJson() {
|
function remoteJson() {
|
||||||
const st = sync.state;
|
const st = sync.state;
|
||||||
if (!otherActive(st)) return null;
|
if (!otherActive(st)) return null;
|
||||||
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
|
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0,
|
||||||
|
sleep: remoteSleep(st)};
|
||||||
const radio = st.kind === 'radio';
|
const radio = st.kind === 'radio';
|
||||||
if (radio) {
|
if (radio) {
|
||||||
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
|
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
|
||||||
|
|
@ -1129,16 +1183,17 @@
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What this device plays, as a report has it.
|
// What this device plays, as a report has it (with the sleep timer).
|
||||||
function mine() {
|
function mine() {
|
||||||
const st = typeof currentStation !== 'undefined' ? currentStation : null;
|
const st = typeof currentStation !== 'undefined' ? currentStation : null;
|
||||||
|
const zz = {left_ms: Math.max(0, Math.round(sleep.leftMs)), episodes: sleep.episodes};
|
||||||
if (st && (radioPlaying() || !cur)) {
|
if (st && (radioPlaying() || !cur)) {
|
||||||
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
|
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
|
||||||
position_ms: 0, playing: radioPlaying()};
|
position_ms: 0, playing: radioPlaying(), sleep: zz};
|
||||||
}
|
}
|
||||||
if (cur) {
|
if (cur) {
|
||||||
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
|
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
|
||||||
playing: wantPlay, speed: speedFor(curFeed())};
|
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
|
||||||
}
|
}
|
||||||
return {kind: '', position_ms: 0, playing: false};
|
return {kind: '', position_ms: 0, playing: false};
|
||||||
}
|
}
|
||||||
|
|
@ -1256,6 +1311,10 @@
|
||||||
try {
|
try {
|
||||||
if (a === 'transfer') {
|
if (a === 'transfer') {
|
||||||
takeOver(st);
|
takeOver(st);
|
||||||
|
} else if (a === 'sleep') {
|
||||||
|
// minutes; -n: after n episodes; 0: off
|
||||||
|
const v = Math.round(Number(cmd.value) || 0);
|
||||||
|
sleep.set(Math.max(v, 0), v < 0 ? -v : 0);
|
||||||
} else if (kind === 'radio') {
|
} else if (kind === 'radio') {
|
||||||
if (a === 'play' && !radioPlaying()) togglePlayStop();
|
if (a === 'play' && !radioPlaying()) togglePlayStop();
|
||||||
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
|
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
|
||||||
|
|
@ -1387,7 +1446,7 @@
|
||||||
// Remote control: a command for the active device (or `to`).
|
// Remote control: a command for the active device (or `to`).
|
||||||
function command(action, value, to) {
|
function command(action, value, to) {
|
||||||
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
|
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
|
||||||
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
|
if (action === 'seek' || action === 'skip' || action === 'sleep') body.value = Math.round(Number(value) || 0);
|
||||||
if (to) body.to_device_id = to;
|
if (to) body.to_device_id = to;
|
||||||
else hintFor(action, body.value);
|
else hintFor(action, body.value);
|
||||||
return post('/podcasts/player/command/', body)
|
return post('/podcasts/player/command/', body)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v69';
|
const CACHE = 'diora-v70';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copied from diora-android ee1a910 2026-10-02: web-native/static/native-podcast.js
|
// Copied from diora-android abda34a 2026-10-03: web-native/static/native-podcast.js
|
||||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
// 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.
|
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||||
|
|
||||||
|
|
@ -625,7 +625,7 @@
|
||||||
// The account's presets; without any, the app's own list.
|
// The account's presets; without any, the app's own list.
|
||||||
{key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length
|
{key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length
|
||||||
? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')},
|
? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')},
|
||||||
{key: 'sleep', label: 'Sleep-Timer', hint: () => '15 Minuten an / aus'},
|
{key: 'sleep', label: 'Sleep-Timer', hint: () => `${sleepMinutesLabel((player.sleep || {}).last || 15)} an / aus, auch beim Radio`},
|
||||||
];
|
];
|
||||||
function notifyItem() {
|
function notifyItem() {
|
||||||
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
|
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
|
||||||
|
|
@ -2812,6 +2812,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bar names a running sleep timer: "… · ☾ 12 min".
|
||||||
|
function sleepSuffix() {
|
||||||
|
const l = sleepLabel();
|
||||||
|
return l ? ' · ' + l : '';
|
||||||
|
}
|
||||||
|
|
||||||
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
|
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
|
||||||
// (another device is the active one) or ''.
|
// (another device is the active one) or ''.
|
||||||
let miniKind = '';
|
let miniKind = '';
|
||||||
|
|
@ -2852,7 +2858,7 @@
|
||||||
if (r) {
|
if (r) {
|
||||||
const onRadio = r.kind === 'radio';
|
const onRadio = r.kind === 'radio';
|
||||||
mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
|
mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
|
||||||
mini.querySelector('.nui-mini-track').textContent = remoteLine(r);
|
mini.querySelector('.nui-mini-track').textContent = remoteLine(r) + sleepSuffix();
|
||||||
const t = mini.querySelector('.nui-mini-toggle');
|
const t = mini.querySelector('.nui-mini-toggle');
|
||||||
t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
|
t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
|
||||||
t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r);
|
t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r);
|
||||||
|
|
@ -2863,8 +2869,8 @@
|
||||||
}
|
}
|
||||||
if (miniKind === 'radio') {
|
if (miniKind === 'radio') {
|
||||||
mini.querySelector('.nui-mini-station').textContent = radio.name;
|
mini.querySelector('.nui-mini-station').textContent = radio.name;
|
||||||
mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …'
|
mini.querySelector('.nui-mini-track').textContent = (radio.buffering ? 'Verbinde …'
|
||||||
: radio.playing ? (radio.track || '● Live') : 'Angehalten';
|
: radio.playing ? (radio.track || '● Live') : 'Angehalten') + sleepSuffix();
|
||||||
const t = mini.querySelector('.nui-mini-toggle');
|
const t = mini.querySelector('.nui-mini-toggle');
|
||||||
t.textContent = radio.playing ? '■' : '▶';
|
t.textContent = radio.playing ? '■' : '▶';
|
||||||
t.title = radio.playing ? 'Stopp' : 'Abspielen';
|
t.title = radio.playing ? 'Stopp' : 'Abspielen';
|
||||||
|
|
@ -2881,8 +2887,9 @@
|
||||||
sk.title = `${devicePrefs().skip_back || 15} s zurück`;
|
sk.title = `${devicePrefs().skip_back || 15} s zurück`;
|
||||||
sk.setAttribute('aria-label', sk.title);
|
sk.setAttribute('aria-label', sk.title);
|
||||||
}
|
}
|
||||||
mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …'
|
mini.querySelector('.nui-mini-track').textContent = (player.buffering && player.playing ? 'Lädt …'
|
||||||
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : '');
|
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''))
|
||||||
|
+ sleepSuffix();
|
||||||
const t = mini.querySelector('.nui-mini-toggle');
|
const t = mini.querySelector('.nui-mini-toggle');
|
||||||
t.textContent = player.playing ? '❚❚' : '▶';
|
t.textContent = player.playing ? '❚❚' : '▶';
|
||||||
t.title = player.playing ? 'Pause' : 'Abspielen';
|
t.title = player.playing ? 'Pause' : 'Abspielen';
|
||||||
|
|
@ -3494,44 +3501,150 @@
|
||||||
].concat(presetsEditItem()));
|
].concat(presetsEditItem()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- sleep timer (runs in the app, also with the screen off) ----
|
// ---- sleep timer (runs in the app, also with the screen off; episodes and the radio) ----
|
||||||
|
|
||||||
|
// The timer that counts: the other device's while that one is the active one, else ours.
|
||||||
|
function sleepState() {
|
||||||
|
const r = remote();
|
||||||
|
return (r ? r.sleep : player.sleep) || {};
|
||||||
|
}
|
||||||
|
|
||||||
function sleepLabel() {
|
function sleepLabel() {
|
||||||
const t = player.sleep || {};
|
const t = sleepState();
|
||||||
if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`;
|
if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`;
|
||||||
if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`;
|
if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minutes count only while playing; shaking the phone starts them over.
|
const SLEEP_MINUTES = [5, 10, 15, 20, 30, 45, 60, 90];
|
||||||
|
const SLEEP_MAX = 600;
|
||||||
|
|
||||||
|
// A typed-in time: "25", "25 min", "1:30", "1,5 h" → minutes (1 to 600), else null.
|
||||||
|
function parseSleepMinutes(text) {
|
||||||
|
const t = String(text || '').trim().toLowerCase().replace(',', '.').replace(/\s*(minuten|min|m)$/, '');
|
||||||
|
let m;
|
||||||
|
const hm = /^(\d+):(\d{1,2})$/.exec(t);
|
||||||
|
const h = /^(\d+(?:\.\d+)?)\s*(h|std|stunden?)$/.exec(t);
|
||||||
|
if (hm) m = Number(hm[2]) < 60 ? Number(hm[1]) * 60 + Number(hm[2]) : NaN;
|
||||||
|
else if (h) m = Math.round(Number(h[1]) * 60);
|
||||||
|
else m = /^\d+(\.\d+)?$/.test(t) ? Math.round(Number(t)) : NaN;
|
||||||
|
return m >= 1 && m <= SLEEP_MAX ? m : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepMinutesLabel(m) {
|
||||||
|
if (m === 1) return '1 Minute';
|
||||||
|
return m < 60 ? `${m} Minuten` : `${Math.floor(m / 60)} h` + (m % 60 ? ` ${m % 60} min` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hourOf(v, fallback) {
|
||||||
|
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepWindow() {
|
||||||
|
const p = devicePrefs();
|
||||||
|
const from = hourOf(p.sleep_from, 22);
|
||||||
|
const to = hourOf(p.sleep_to, 6);
|
||||||
|
return from === to ? 'ganztags' : `${from}–${to} Uhr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minutes count only while playing; shaking the phone starts them over. With another
|
||||||
|
// device playing, the timer is that device's (set there by the player sync).
|
||||||
function sleepMenu(radio) {
|
function sleepMenu(radio) {
|
||||||
if (!N.podcastSleep) return;
|
const r = remote();
|
||||||
const t = player.sleep || {};
|
const set = r ? (m => remoteCmd('sleep', m)) : N.podcastSleep ? (m => N.podcastSleep(m)) : null;
|
||||||
|
if (!set) return;
|
||||||
|
const onRadio = r ? r.kind === 'radio' : radio === true;
|
||||||
|
const t = sleepState();
|
||||||
|
const own = player.sleep || {};
|
||||||
const on = !!sleepLabel();
|
const on = !!sleepLabel();
|
||||||
const items = [];
|
const items = [];
|
||||||
|
if (r) items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote', `Der Sleep-Timer gilt für ${remoteName(r)}.`)});
|
||||||
if (on) {
|
if (on) {
|
||||||
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)});
|
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => set(0)});
|
||||||
if (t.leftMs > 0 && N.podcastSleepExtend) {
|
if (t.leftMs > 0) {
|
||||||
items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)});
|
for (const m of [5, 10]) {
|
||||||
items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)});
|
items.push({icon: '+', label: `${m} Minuten länger`, action: () => (!r && N.podcastSleepExtend
|
||||||
|
? N.podcastSleepExtend(m) : set(Math.min(SLEEP_MAX, Math.ceil(t.leftMs / 60000) + m)))});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const m of [5, 10, 15, 30, 45, 60, 90]) {
|
|
||||||
items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
|
|
||||||
}
|
}
|
||||||
if (radio !== true) {
|
for (const m of SLEEP_MINUTES) {
|
||||||
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
|
items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
|
||||||
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
|
|
||||||
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
|
|
||||||
}
|
}
|
||||||
if (N.podcastSleepAuto) {
|
if (typeof customPrompt === 'function') {
|
||||||
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)',
|
// A time of one's own that was set last comes back as its own line.
|
||||||
hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
|
const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0;
|
||||||
action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }});
|
if (lastOwn) items.push({icon: '☾', label: sleepMinutesLabel(lastOwn), hint: on ? null : 'zuletzt', action: () => set(lastOwn)});
|
||||||
|
items.push({icon: '✎', label: 'Eigene Zeit …', hint: 'z. B. 25, 2 h oder 1:30', action: () => customSleep(set)});
|
||||||
|
}
|
||||||
|
if (!onRadio) {
|
||||||
|
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => set(-1)});
|
||||||
|
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => set(-2)});
|
||||||
|
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => set(-3)});
|
||||||
|
}
|
||||||
|
if (!r && N.podcastSleepAuto) {
|
||||||
|
items.push({icon: own.auto ? '☑' : '☐', label: 'Nachts von selbst',
|
||||||
|
hint: own.auto ? `an: ${sleepWindow()}, ${sleepMinutesLabel(own.last || 15)}, sobald etwas startet`
|
||||||
|
: `aus · ${sleepWindow()}`,
|
||||||
|
action: () => {
|
||||||
|
N.podcastSleepAuto(!own.auto);
|
||||||
|
own.auto = !own.auto;
|
||||||
|
toast(own.auto ? `Sleep-Timer nachts (${sleepWindow()}) von selbst` : 'Sleep-Timer nachts: aus');
|
||||||
|
}});
|
||||||
|
items.push({icon: '⚙', label: 'Einstellungen', hint: sleepSettingsHint(), action: sleepSettings});
|
||||||
}
|
}
|
||||||
openSheet('Sleep-Timer', items);
|
openSheet('Sleep-Timer', items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function customSleep(set) {
|
||||||
|
const v = await customPrompt('Sleep-Timer: wie lange? (Minuten, z. B. 25, 2 h oder 1:30)', '');
|
||||||
|
if (v == null || !String(v).trim()) return;
|
||||||
|
const m = parseSleepMinutes(v);
|
||||||
|
if (!m) { toast('Bitte 1 Minute bis 10 Stunden, z. B. 25, 2 h oder 1:30'); return; }
|
||||||
|
set(m);
|
||||||
|
toast(`Sleep-Timer: ${sleepMinutesLabel(m)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepSettingsHint() {
|
||||||
|
const p = devicePrefs();
|
||||||
|
const parts = [`nachts = ${sleepWindow()}`];
|
||||||
|
if ('sleep_shake' in p) parts.push(p.sleep_shake ? 'schütteln an' : 'schütteln aus');
|
||||||
|
if (p.sleep_vibrate) parts.push('vibrieren');
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The night window and, in the app, shaking and buzzing.
|
||||||
|
function sleepSettings() {
|
||||||
|
const p = devicePrefs();
|
||||||
|
const from = hourOf(p.sleep_from, 22);
|
||||||
|
const to = hourOf(p.sleep_to, 6);
|
||||||
|
const again = () => setTimeout(sleepSettings, 60);
|
||||||
|
const setHour = (key, h) => { if (N.podcastSetPrefValue) N.podcastSetPrefValue(key, h); again(); };
|
||||||
|
const items = [
|
||||||
|
{icon: '◐', label: 'Nachts beginnt um', hint: `${from} Uhr`,
|
||||||
|
action: () => hourPicker('Nachts beginnt um …', from, h => setHour('sleep_from', h))},
|
||||||
|
{icon: '◑', label: 'Nachts endet um', hint: `${to} Uhr`,
|
||||||
|
action: () => hourPicker('Nachts endet um …', to, h => setHour('sleep_to', h))},
|
||||||
|
];
|
||||||
|
if ('sleep_shake' in p && N.podcastSetPref) {
|
||||||
|
items.push({icon: p.sleep_shake ? '☑' : '☐', label: 'Schütteln startet die Zeit neu',
|
||||||
|
hint: p.sleep_shake ? 'an: Handy schütteln, und die Minuten laufen von vorn' : 'aus',
|
||||||
|
action: () => { N.podcastSetPref('sleep_shake', !p.sleep_shake); again(); }});
|
||||||
|
}
|
||||||
|
if ('sleep_vibrate' in p && N.podcastSetPref) {
|
||||||
|
items.push({icon: p.sleep_vibrate ? '☑' : '☐', label: 'Vor dem Ende vibrieren',
|
||||||
|
hint: p.sleep_vibrate ? 'an: kurz, wenn die letzten 10 s leiser werden' : 'aus',
|
||||||
|
action: () => { N.podcastSetPref('sleep_vibrate', !p.sleep_vibrate); again(); }});
|
||||||
|
}
|
||||||
|
openSheet('Sleep-Timer: Einstellungen', items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hourPicker(title, now, done) {
|
||||||
|
const hours = [];
|
||||||
|
for (let i = 0; i < 24; i++) hours.push((18 + i) % 24);
|
||||||
|
openSheet(title, hours.map(h => ({icon: h === now ? '●' : '○', label: `${h} Uhr`, action: () => done(h)})));
|
||||||
|
}
|
||||||
|
|
||||||
// ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept
|
// ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept
|
||||||
// unplayed in the queue, or played. With nothing after it, it only counts as played
|
// unplayed in the queue, or played. With nothing after it, it only counts as played
|
||||||
// when skipped episodes aren't kept.
|
// when skipped episodes aren't kept.
|
||||||
|
|
@ -3675,6 +3788,7 @@
|
||||||
}
|
}
|
||||||
const extra = el('div', 'nui-pod-extra');
|
const extra = el('div', 'nui-pod-extra');
|
||||||
if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next'));
|
if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next'));
|
||||||
|
extra.append(button('☾', `Sleep-Timer auf ${remoteName(r)}`, () => sleepMenu(onRadio), 'nui-pod-sleep'));
|
||||||
if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'));
|
if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'));
|
||||||
if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue));
|
if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue));
|
||||||
panel.append(extra);
|
panel.append(extra);
|
||||||
|
|
@ -3697,6 +3811,9 @@
|
||||||
const p = sheet.querySelector('.nui-pod-play');
|
const p = sheet.querySelector('.nui-pod-play');
|
||||||
p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
|
p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
|
||||||
p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r);
|
p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r);
|
||||||
|
const sl = sheet.querySelector('.nui-pod-sleep');
|
||||||
|
sl.textContent = sleepLabel() || '☾';
|
||||||
|
sl.classList.toggle('on', !!sleepLabel());
|
||||||
if (onRadio) return;
|
if (onRadio) return;
|
||||||
sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || '';
|
sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || '';
|
||||||
const range = sheet.querySelector('.nui-pod-seek');
|
const range = sheet.querySelector('.nui-pod-seek');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* Copied from diora-android ee1a910 2026-10-02: the podcast parts of web-native/static/native.css
|
/* Copied from diora-android abda34a 2026-10-03: the podcast parts of web-native/static/native.css
|
||||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||||
The web's own additions are in podcasts-web.css. */
|
The web's own additions are in podcasts-web.css. */
|
||||||
|
|
||||||
|
|
@ -775,3 +775,8 @@ body.eink .nui-pod-chapbox { border-color: #000; }
|
||||||
body.eink .nui-pod-chip.on { border-color: #000; color: #000; }
|
body.eink .nui-pod-chip.on { border-color: #000; color: #000; }
|
||||||
body.eink .nui-pod-notes a,
|
body.eink .nui-pod-notes a,
|
||||||
body.eink .nui-pod-facts a { text-decoration-color: #000; }
|
body.eink .nui-pod-facts a { text-decoration-color: #000; }
|
||||||
|
|
||||||
|
/* A prompt (customPrompt: an own sleep time, an own speed) opened from the player or a
|
||||||
|
sheet comes above both (app.css has it at 500/501, under them). */
|
||||||
|
.modal-overlay { z-index: 990; }
|
||||||
|
.modal-dialog { z-index: 991; }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue