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
|
||||
# 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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue