Sleep-Timer: eigene Zeit, Nachtfenster, auch fürs Radio, von anderen Geräten setzen; sw v70
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m54s

- 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:
mrwnslz 2026-10-03 12:58:55 +02:00
parent 985e635f9a
commit f12451f30e
8 changed files with 298 additions and 43 deletions

View 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),
),
]

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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',

View file

@ -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]) { for (const m of SLEEP_MINUTES) {
items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)}); items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
} }
if (radio !== true) { if (typeof customPrompt === 'function') {
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); // A time of one's own that was set last comes back as its own line.
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0;
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); 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 (N.podcastSleepAuto) { if (!onRadio) {
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)', items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => set(-1)});
hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus', items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => set(-2)});
action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }}); 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');

View file

@ -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; }