feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts grows an optional action button; acting dismisses). - Chain: 'End of chapter, then 15 min' — leaving the chapter starts a 15-min countdown instead of fading (SleepState.followUpMinutes). - Clock: the popover gains a time input + Arm — armClockTime resolves HH:MM to its next occurrence and reuses the minutes machinery. - Dim: the ticker eases a pointer-events-none overlay toward 35% black across a countdown's final 30s; stop/finish resets it. (Fade-out and countdown labels already existed — §3 built them.)
This commit is contained in:
1 parent
4769e832c4
commit
f8de17723a
6 files changed
+218
-21
No files matched your search
+55
-3
@@ -25,12 +25,14 @@ import {
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
armClockTime,
|
||||
armEndOfChapter,
|
||||
armMinutes,
|
||||
fadeVolume,
|
||||
sleepChapterFired,
|
||||
sleepDue,
|
||||
sleepLabel,
|
||||
sleepRemainingMs,
|
||||
type SleepChoice,
|
||||
type SleepState,
|
||||
} from "./audio-sleep";
|
||||
@@ -284,6 +286,11 @@ Alpine.data("audioReader", () => ({
|
||||
sleepArmedChoiceKey: "off",
|
||||
sleepTicker: null as ReturnType<typeof setInterval> | null,
|
||||
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
|
||||
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
|
||||
sleepClockTime: "23:00",
|
||||
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
|
||||
// 30s of a countdown. Reset by stop/finish.
|
||||
sleepDim: 0,
|
||||
|
||||
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
|
||||
// is the first option; the panel is structured for more.
|
||||
@@ -579,7 +586,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.currentChapterIdx = i;
|
||||
this.updateMediaSessionMetadata();
|
||||
// Natural chapter crossing: fire an end-of-chapter sleep.
|
||||
if (sleepChapterFired(this.sleep, i)) this.beginSleepFade();
|
||||
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -637,7 +644,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.currentChapterIdx = index;
|
||||
// Manual jump: leaving the armed chapter fires an end-of-chapter
|
||||
// sleep the same as a natural crossing.
|
||||
if (sleepChapterFired(this.sleep, index)) this.beginSleepFade();
|
||||
this.onChapterCrossing(index);
|
||||
},
|
||||
|
||||
setSpeed(rate: number) {
|
||||
@@ -1139,7 +1146,12 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
if (choice.mode === "end-of-chapter") {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armEndOfChapter(this.currentChapterIdx);
|
||||
// §17 chain: "end of chapter, then N min" — leaving the chapter
|
||||
// starts the countdown instead of fading straight away.
|
||||
this.sleep = armEndOfChapter(
|
||||
this.currentChapterIdx,
|
||||
choice.chain ? (choice.minutes ?? 15) : null,
|
||||
);
|
||||
} else {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
|
||||
@@ -1148,6 +1160,32 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
},
|
||||
|
||||
// §17 clock mode: arm against the next occurrence of the popover's
|
||||
// HH:MM input. Unparseable input keeps the current state.
|
||||
chooseSleepClock() {
|
||||
const armed = armClockTime(Date.now(), this.sleepClockTime);
|
||||
if (!armed) return;
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armed;
|
||||
this.sleepArmedChoiceKey = "clock";
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
this.startSleepTicker();
|
||||
},
|
||||
|
||||
// A chapter crossing fires an end-of-chapter sleep: straight to the
|
||||
// fade — or (§17 chain arm) convert to the follow-up countdown.
|
||||
onChapterCrossing(index: number) {
|
||||
if (!sleepChapterFired(this.sleep, index)) return;
|
||||
const follow = this.sleep.followUpMinutes;
|
||||
if (follow !== null) {
|
||||
this.sleep = armMinutes(Date.now(), follow);
|
||||
this.startSleepTicker();
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
} else {
|
||||
this.beginSleepFade();
|
||||
}
|
||||
},
|
||||
|
||||
startSleepTicker() {
|
||||
this.stopSleepTicker();
|
||||
this.sleepTicker = setInterval(() => {
|
||||
@@ -1159,6 +1197,10 @@ Alpine.data("audioReader", () => ({
|
||||
return;
|
||||
}
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, now);
|
||||
// §17 wind-down dim: ease toward 35% black across the final 30s.
|
||||
const remaining = sleepRemainingMs(this.sleep, now);
|
||||
this.sleepDim =
|
||||
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
@@ -1208,6 +1250,15 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
this.sleepDim = 0;
|
||||
// §17: a genuine fire can catch you still awake — one-click undo.
|
||||
showToast("Sleep timer: playback paused", "info", 8000, {
|
||||
label: "Resume",
|
||||
onClick: () => {
|
||||
const a = this.audio();
|
||||
if (a) a.play().catch(() => {});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Manual off / play-abort: undo any partial fade.
|
||||
@@ -1222,6 +1273,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
this.sleepDim = 0;
|
||||
},
|
||||
|
||||
// ---- settings popover (plan §4) ----
|
||||
|
||||
Reference in new issue
Block a user