From f12451f30e6dc35704183c238e5883291c6d59d2 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sat, 3 Oct 2026 12:58:55 +0200 Subject: [PATCH] =?UTF-8?q?Sleep-Timer:=20eigene=20Zeit,=20Nachtfenster,?= =?UTF-8?q?=20auch=20f=C3=BCrs=20Radio,=20von=20anderen=20Ger=C3=A4ten=20s?= =?UTF-8?q?etzen;=20sw=20v70?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- podcasts/migrations/0016_playerstate_sleep.py | 23 +++ podcasts/models.py | 4 + podcasts/test_player.py | 28 +++ podcasts/views.py | 23 ++- static/js/podcasts-web.js | 85 +++++++-- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 169 +++++++++++++++--- static/podcasts/podcast-ui.css | 7 +- 8 files changed, 298 insertions(+), 43 deletions(-) create mode 100644 podcasts/migrations/0016_playerstate_sleep.py diff --git a/podcasts/migrations/0016_playerstate_sleep.py b/podcasts/migrations/0016_playerstate_sleep.py new file mode 100644 index 0000000..4179625 --- /dev/null +++ b/podcasts/migrations/0016_playerstate_sleep.py @@ -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), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 39eaa6a..45fcbc2 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -347,6 +347,10 @@ class PlayerState(models.Model): # 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) + # 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 def for_user(cls, user): diff --git a/podcasts/test_player.py b/podcasts/test_player.py index 65ed1bb..e7c8214 100644 --- a/podcasts/test_player.py +++ b/podcasts/test_player.py @@ -257,6 +257,34 @@ class PlayerSyncTests(TestCase): with self.subTest(body=body): 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): self.report('a', True, pos=1000) self.report('c', False, pos=0) # c is online (paused report: ignored) diff --git a/podcasts/views.py b/podcasts/views.py index 3ef88d0..3110930 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -2127,7 +2127,9 @@ def clear_import_downloads(request): # --------------------------------------------------------------------------- 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_DAYS = 7 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), 'server_time': _iso_ms(now), 'command': p.command, + 'sleep': {'left_ms': p.sleep_left_ms, 'episodes': p.sleep_episodes}, 'devices': devices, '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], 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'] done = body.get('done_command') 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, playing=playing and bool(kind), speed=float(speed), + sleep_left_ms=sleep_left, + sleep_episodes=sleep_episodes, device_id=device_id, device_name=body['device_name'], updated_at=now, @@ -2348,7 +2360,8 @@ 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.""" + 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: return JsonResponse({'error': 'authentication required'}, status=401) body, error = _player_body(request) @@ -2363,6 +2376,12 @@ def player_command(request): 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) + 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') if to is not None and (not isinstance(to, str) or len(to) > 64): return JsonResponse({'error': 'invalid to_device_id'}, status=400) diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index b2def5f..becb8da 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -429,7 +429,7 @@ 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, - 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. function eff(name) { @@ -449,6 +449,9 @@ if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); } } else if (name === 'remove_played') { 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 { 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. function prefsJson() { 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) { @@ -482,32 +486,57 @@ } // ---- 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 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 = { leftMs: 0, episodes: 0, lastTick: 0, + lastCounted: 0, get active() { return this.leftMs > 0 || this.episodes > 0; }, set(minutes, episodes) { this.leftMs = minutes * 60000; this.episodes = minutes > 0 ? 0 : episodes; this.lastTick = 0; + this.lastCounted = this.active ? Date.now() : 0; if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); } setFade(1); + sleepChanged(); }, extend(minutes) { if (this.leftMs <= 0) { this.set(minutes, 0); return; } this.leftMs += minutes * 60000; + this.lastCounted = Date.now(); setFade(1); + sleepChanged(); }, tick(playing) { const now = Date.now(); const delta = this.lastTick ? now - this.lastTick : 0; 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. if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta; if (this.leftMs <= 0) { @@ -515,6 +544,7 @@ setFade(1); pause(); stopRadio(); // the radio sleeps too (it plays in app.js's element) + sleepChanged(); return; } if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1)); @@ -523,20 +553,32 @@ episodeFinished() { if (this.episodes <= 0) return false; this.episodes--; + if (this.episodes === 0) sleepChanged(); return this.episodes === 0; }, - queueEnded() { this.episodes = 0; }, - // An episode starts playing: at night, the timer may switch itself on. + queueEnded() { + 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() { if (this.active || !prefs.sleep_auto) return; - const hour = new Date().getHours(); - if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0); + if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 6))) { + this.set(prefs.sleep_last || 15, 0); + } }, 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) --------------------------------------- const audio = new Audio(); // the radio keeps app.js's own element @@ -920,6 +962,7 @@ window.playStation = function () { putAway(); const r = playStation.apply(this, arguments); + sleep.maybeAutoEnable(); radioChanged(); sync.start = true; syncSoon(); @@ -1087,11 +1130,22 @@ ((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. 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 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'; if (radio) { o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)}; @@ -1129,16 +1183,17 @@ 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() { 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)) { 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) { 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}; } @@ -1256,6 +1311,10 @@ try { if (a === 'transfer') { 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') { if (a === 'play' && !radioPlaying()) togglePlayStop(); else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false); @@ -1387,7 +1446,7 @@ // 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 (action === 'seek' || action === 'skip' || action === 'sleep') 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) diff --git a/static/js/sw.js b/static/js/sw.js index 00dd508..6dfb5a1 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v69'; +const CACHE = 'diora-v70'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index fc79308..27ca360 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.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 // 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. {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(' · ')}, - {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() { 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' // (another device is the active one) or ''. let miniKind = ''; @@ -2852,7 +2858,7 @@ 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); + mini.querySelector('.nui-mini-track').textContent = remoteLine(r) + sleepSuffix(); 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); @@ -2863,8 +2869,8 @@ } if (miniKind === 'radio') { mini.querySelector('.nui-mini-station').textContent = radio.name; - mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …' - : radio.playing ? (radio.track || '● Live') : 'Angehalten'; + mini.querySelector('.nui-mini-track').textContent = (radio.buffering ? 'Verbinde …' + : radio.playing ? (radio.track || '● Live') : 'Angehalten') + sleepSuffix(); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = radio.playing ? '■' : '▶'; t.title = radio.playing ? 'Stopp' : 'Abspielen'; @@ -2881,8 +2887,9 @@ sk.title = `${devicePrefs().skip_back || 15} s zurück`; sk.setAttribute('aria-label', sk.title); } - 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' : ''); + 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' : '')) + + sleepSuffix(); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = player.playing ? '❚❚' : '▶'; t.title = player.playing ? 'Pause' : 'Abspielen'; @@ -3494,44 +3501,150 @@ ].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() { - const t = player.sleep || {}; + const t = sleepState(); 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`; 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) { - if (!N.podcastSleep) return; - const t = player.sleep || {}; + const r = remote(); + 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 items = []; + if (r) items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote', `Der Sleep-Timer gilt für ${remoteName(r)}.`)}); if (on) { - items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)}); - if (t.leftMs > 0 && N.podcastSleepExtend) { - items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)}); - items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)}); + items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => set(0)}); + if (t.leftMs > 0) { + for (const m of [5, 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)}); + for (const m of SLEEP_MINUTES) { + items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)}); } - if (radio !== true) { - items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); - items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); - items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); + if (typeof customPrompt === 'function') { + // A time of one's own that was set last comes back as its own line. + const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0; + 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) { - items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)', - hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus', - action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }}); + 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); } + 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 // unplayed in the queue, or played. With nothing after it, it only counts as played // when skipped episodes aren't kept. @@ -3675,6 +3788,7 @@ } const extra = el('div', 'nui-pod-extra'); 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 (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue)); panel.append(extra); @@ -3697,6 +3811,9 @@ const p = sheet.querySelector('.nui-pod-play'); p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶'; 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; sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || ''; const range = sheet.querySelector('.nui-pod-seek'); diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 63b901d..d040f06 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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-notes a, 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; }