mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
5138d1aa5c
The AudioController is rebuilt as a thin DOM shell around a pure policy
module (transition(snapshot, event) -> { snapshot, effects }, 199 unit
tests). One status drives all UI; the four overlapping recovery mechanisms
(3s watchdog, 10s stalled-grace, code-2 retry loop, AbortError reload) are
replaced by a single deadline-bounded ladder that distinguishes buffering
from stalling, parks instead of auto-playing while backgrounded, and resets
its attempt budget only after sustained progress. The transport is never
disabled: players accept taps in every state, and a wedged spinner is
structurally impossible (FE2). Native stalled events are ignored entirely
(1094/1094 were noise in the production trace). Truncated deliveries are
recovered at-position instead of advancing the queue (FE10). Lock-screen
pause now routes as a user pause (FE5); terminal network errors surface
uniformly with a working retry (FE7); ended->next keeps the synchronous
event-tail play for the iOS autoplay grant (FE13); mute uses audio.muted
(FE14); the dead prefetch hint and needs-resume plumbing are gone
(FE15/FE18). AudioContext bridge preserved verbatim.
703 lines
23 KiB
TypeScript
703 lines
23 KiB
TypeScript
/**
|
|
* Audio engine policy -- pure state machine.
|
|
* No DOM access, no Date.now(), no randomness. All time comes from events via `now`.
|
|
*/
|
|
|
|
export type EngineStatus =
|
|
| "idle"
|
|
| "loading"
|
|
| "playing"
|
|
| "buffering"
|
|
| "paused"
|
|
| "recovering"
|
|
| "blocked"
|
|
| "error";
|
|
|
|
export type PauseClass = "user" | "system";
|
|
|
|
export type TimerId = "nudge-settle" | "reload-settle";
|
|
|
|
export interface EngineSnapshot {
|
|
status: EngineStatus;
|
|
intent: "play" | "pause";
|
|
pauseClass: PauseClass | null;
|
|
src: string | null;
|
|
generation: number;
|
|
currentTime: number;
|
|
duration: number;
|
|
error: { message: string; code: number } | null;
|
|
attempts: number;
|
|
lastProgressAt: number;
|
|
progressStreakStartedAt: number | null;
|
|
rung: "none" | "nudge-wait" | "reload-wait";
|
|
pendingSeek: number | null;
|
|
resumeOnForeground: boolean;
|
|
// Tracks document.hidden from the most recent tick delivered to us.
|
|
lastKnownHidden: boolean;
|
|
}
|
|
|
|
export type EngineEvent =
|
|
| { type: "load"; src: string; autoplay: boolean; seekTo?: number; now: number }
|
|
| { type: "play-requested"; now: number }
|
|
| { type: "pause-requested"; cls: PauseClass; now: number }
|
|
| { type: "native-playing"; now: number }
|
|
| { type: "native-pause"; now: number }
|
|
| { type: "native-ended"; currentTime: number; duration: number; now: number }
|
|
| { type: "native-canplay"; duration: number; now: number }
|
|
| { type: "native-waiting"; now: number }
|
|
| { type: "native-error"; code: number; message: string; now: number }
|
|
| {
|
|
type: "tick";
|
|
currentTime: number;
|
|
readyState: number;
|
|
paused: boolean;
|
|
hidden: boolean;
|
|
now: number;
|
|
}
|
|
| { type: "timer-fired"; timer: TimerId; now: number }
|
|
| {
|
|
type: "play-rejected";
|
|
reason: "not-allowed" | "abort" | "other";
|
|
generation: number;
|
|
now: number;
|
|
}
|
|
| { type: "foreground"; now: number }
|
|
| { type: "cleanup"; now: number };
|
|
|
|
export type Effect =
|
|
| { kind: "set-src-and-load"; src: string }
|
|
| { kind: "seek"; time: number }
|
|
| { kind: "call-play" }
|
|
| { kind: "call-pause" }
|
|
| { kind: "arm-timer"; timer: TimerId; ms: number }
|
|
| { kind: "cancel-timer"; timer: TimerId }
|
|
| { kind: "claim-session" }
|
|
| { kind: "emit-ended" }
|
|
| { kind: "emit-error" };
|
|
|
|
export const RECOVERY = {
|
|
bufferingDeadlineMs: 30_000,
|
|
stallDeadlineMs: 5_000,
|
|
nudgeSettleMs: 4_000,
|
|
reloadSettleMs: 12_000,
|
|
maxAttempts: 4,
|
|
budgetResetAfterMs: 60_000,
|
|
endedToleranceS: 3,
|
|
} as const;
|
|
|
|
// Status set for which the recovery ladder is active.
|
|
const LADDER_ACTIVE_STATUSES = new Set<EngineStatus>([
|
|
"playing",
|
|
"buffering",
|
|
"recovering",
|
|
]);
|
|
|
|
// Status set that allows immediate escalation (not idle/paused/blocked/error).
|
|
const ESCALATABLE_STATUSES = new Set<EngineStatus>([
|
|
"playing",
|
|
"buffering",
|
|
"loading",
|
|
"recovering",
|
|
]);
|
|
|
|
export function initialSnapshot(): EngineSnapshot {
|
|
return {
|
|
status: "idle",
|
|
intent: "pause",
|
|
pauseClass: null,
|
|
src: null,
|
|
generation: 0,
|
|
currentTime: 0,
|
|
duration: 0,
|
|
error: null,
|
|
attempts: 0,
|
|
lastProgressAt: 0,
|
|
progressStreakStartedAt: null,
|
|
rung: "none",
|
|
pendingSeek: null,
|
|
resumeOnForeground: false,
|
|
lastKnownHidden: false,
|
|
};
|
|
}
|
|
|
|
// Cancel both timers -- used whenever we leave the ladder.
|
|
function cancelBothTimers(): Effect[] {
|
|
return [
|
|
{ kind: "cancel-timer", timer: "nudge-settle" },
|
|
{ kind: "cancel-timer", timer: "reload-settle" },
|
|
];
|
|
}
|
|
|
|
type TransitionResult = { snapshot: EngineSnapshot; effects: Effect[] };
|
|
|
|
/**
|
|
* Start the nudge rung. Increments attempts and arms the nudge-settle timer.
|
|
* Does NOT mutate the input.
|
|
*/
|
|
function startNudgeRung(snap: EngineSnapshot, now: number): TransitionResult {
|
|
void now;
|
|
const newAttempts = snap.attempts + 1;
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "recovering",
|
|
rung: "nudge-wait",
|
|
attempts: newAttempts,
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [
|
|
{ kind: "cancel-timer", timer: "reload-settle" },
|
|
{ kind: "seek", time: snap.currentTime },
|
|
{ kind: "arm-timer", timer: "nudge-settle", ms: RECOVERY.nudgeSettleMs },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start the reload rung. Does NOT increment attempts (caller already did via
|
|
* startNudgeRung, or this is a direct escalation from native-error).
|
|
* Applies the park policy: if hidden, park instead of calling play.
|
|
*/
|
|
function startReloadRung(
|
|
snap: EngineSnapshot,
|
|
resumeAt: number,
|
|
_now: number,
|
|
): TransitionResult {
|
|
if (!snap.src) {
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "error",
|
|
error: { message: "Playback stalled repeatedly", code: 99 },
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-error" }],
|
|
};
|
|
}
|
|
|
|
const pendingSeek = resumeAt > 0 ? resumeAt : null;
|
|
|
|
// Park policy: if hidden, do not call play.
|
|
if (snap.lastKnownHidden) {
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "paused",
|
|
pauseClass: "system",
|
|
rung: "none",
|
|
resumeOnForeground: true,
|
|
pendingSeek,
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [
|
|
...cancelBothTimers(),
|
|
{ kind: "set-src-and-load", src: snap.src },
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "recovering",
|
|
rung: "reload-wait",
|
|
pendingSeek,
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [
|
|
{ kind: "cancel-timer", timer: "nudge-settle" },
|
|
{ kind: "set-src-and-load", src: snap.src },
|
|
{ kind: "arm-timer", timer: "reload-settle", ms: RECOVERY.reloadSettleMs },
|
|
{ kind: "claim-session" },
|
|
{ kind: "call-play" },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Direct escalation to error terminal state.
|
|
*/
|
|
function enterError(
|
|
snap: EngineSnapshot,
|
|
message: string,
|
|
code: number,
|
|
): TransitionResult {
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "error",
|
|
error: { message, code },
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-error" }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Escalate: pick nudge first, then reload, then error.
|
|
* Increments attempts before nudge rung (counted once per rung pair).
|
|
* For direct escalation (native-error) that skips nudge, attempts++ is
|
|
* also done here to keep the semantics consistent.
|
|
*/
|
|
function escalate(
|
|
snap: EngineSnapshot,
|
|
now: number,
|
|
skipNudge: boolean,
|
|
): TransitionResult {
|
|
if (snap.attempts >= RECOVERY.maxAttempts) {
|
|
return enterError(snap, "Playback stalled repeatedly", 99);
|
|
}
|
|
|
|
if (skipNudge) {
|
|
const withAttempts = { ...snap, attempts: snap.attempts + 1 };
|
|
if (withAttempts.attempts >= RECOVERY.maxAttempts) {
|
|
return enterError(withAttempts, "Playback stalled repeatedly", 99);
|
|
}
|
|
return startReloadRung(withAttempts, snap.currentTime, now);
|
|
}
|
|
|
|
return startNudgeRung(snap, now);
|
|
}
|
|
|
|
export function transition(
|
|
snap: EngineSnapshot,
|
|
event: EngineEvent,
|
|
): { snapshot: EngineSnapshot; effects: Effect[] } {
|
|
switch (event.type) {
|
|
case "load": {
|
|
const effects: Effect[] = [
|
|
...cancelBothTimers(),
|
|
{ kind: "set-src-and-load", src: event.src },
|
|
];
|
|
if (event.autoplay) {
|
|
effects.push({ kind: "claim-session" });
|
|
effects.push({ kind: "call-play" });
|
|
}
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "loading",
|
|
src: event.src,
|
|
generation: snap.generation + 1,
|
|
attempts: 0,
|
|
rung: "none",
|
|
pendingSeek: event.seekTo ?? null,
|
|
intent: event.autoplay ? "play" : snap.intent,
|
|
error: null,
|
|
progressStreakStartedAt: null,
|
|
resumeOnForeground: false,
|
|
lastProgressAt: event.now,
|
|
currentTime: 0,
|
|
duration: snap.duration,
|
|
},
|
|
effects,
|
|
};
|
|
}
|
|
|
|
case "play-requested": {
|
|
// From error or blocked: treat as fresh retry.
|
|
if (snap.status === "error" || snap.status === "blocked") {
|
|
if (snap.src) {
|
|
const pendingSeek = snap.currentTime > 0 ? snap.currentTime : null;
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "loading",
|
|
intent: "play",
|
|
attempts: 0,
|
|
rung: "none",
|
|
error: null,
|
|
pendingSeek,
|
|
progressStreakStartedAt: null,
|
|
lastProgressAt: event.now,
|
|
},
|
|
effects: [
|
|
...cancelBothTimers(),
|
|
{ kind: "claim-session" },
|
|
{ kind: "set-src-and-load", src: snap.src },
|
|
{ kind: "call-play" },
|
|
],
|
|
};
|
|
}
|
|
// No src -- just update intent and claim session.
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
intent: "play",
|
|
error: null,
|
|
status: snap.src ? "loading" : "idle",
|
|
},
|
|
effects: [{ kind: "claim-session" }, { kind: "call-play" }],
|
|
};
|
|
}
|
|
return {
|
|
snapshot: { ...snap, intent: "play" },
|
|
effects: [{ kind: "claim-session" }, { kind: "call-play" }],
|
|
};
|
|
}
|
|
|
|
case "pause-requested": {
|
|
const newIntent = event.cls === "user" ? "pause" : snap.intent;
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "paused",
|
|
intent: newIntent,
|
|
pauseClass: event.cls,
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "call-pause" }],
|
|
};
|
|
}
|
|
|
|
case "native-playing": {
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "playing",
|
|
pauseClass: null,
|
|
rung: "none",
|
|
progressStreakStartedAt: snap.progressStreakStartedAt ?? event.now,
|
|
lastProgressAt: event.now,
|
|
},
|
|
effects: cancelBothTimers(),
|
|
};
|
|
}
|
|
|
|
case "native-pause": {
|
|
// Unexpected pause from native element (not initiated by our pause-requested).
|
|
// Leave intent unchanged.
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "paused",
|
|
pauseClass: "system",
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: cancelBothTimers(),
|
|
};
|
|
}
|
|
|
|
case "native-ended": {
|
|
const { currentTime, duration } = event;
|
|
const isHonest =
|
|
isFinite(duration) &&
|
|
duration > 0 &&
|
|
currentTime >= duration - RECOVERY.endedToleranceS;
|
|
|
|
if (!isHonest && snap.src && ESCALATABLE_STATUSES.has(snap.status)) {
|
|
// Truncated delivery -- escalate like native-error (skip nudge).
|
|
const updated: EngineSnapshot = {
|
|
...snap,
|
|
currentTime: currentTime,
|
|
duration: duration,
|
|
};
|
|
const result = escalate(updated, event.now, true);
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "paused",
|
|
currentTime: currentTime,
|
|
duration: duration,
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-ended" }],
|
|
};
|
|
}
|
|
|
|
case "native-canplay": {
|
|
const effects: Effect[] = [];
|
|
let nextSnap: EngineSnapshot = { ...snap, duration: event.duration };
|
|
|
|
// Apply pending seek.
|
|
if (snap.pendingSeek !== null) {
|
|
effects.push({ kind: "seek", time: snap.pendingSeek });
|
|
nextSnap = { ...nextSnap, pendingSeek: null };
|
|
}
|
|
|
|
// Resolve reload-wait rung -- cancel the reload-settle timer but keep status
|
|
// as determined below.
|
|
if (snap.rung === "reload-wait") {
|
|
effects.push({ kind: "cancel-timer", timer: "reload-settle" });
|
|
// Do NOT cancel nudge-settle (it is not armed during reload-wait).
|
|
nextSnap = { ...nextSnap, rung: "none" };
|
|
}
|
|
|
|
// Status: if intent is pause, stay paused/loading->paused.
|
|
if (snap.intent === "pause") {
|
|
nextSnap = {
|
|
...nextSnap,
|
|
status: "paused",
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
};
|
|
// Remove reload-settle cancel if we added it and cancel both instead.
|
|
return {
|
|
snapshot: nextSnap,
|
|
effects: [...cancelBothTimers(), ...effects.filter(
|
|
(e) => e.kind !== "cancel-timer",
|
|
)],
|
|
};
|
|
}
|
|
|
|
// Otherwise keep status unchanged; native-playing confirms playing.
|
|
return { snapshot: nextSnap, effects };
|
|
}
|
|
|
|
case "native-waiting": {
|
|
if (snap.status === "playing") {
|
|
return {
|
|
snapshot: { ...snap, status: "buffering" },
|
|
effects: [],
|
|
};
|
|
}
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
|
|
case "native-error": {
|
|
const { code, message } = event;
|
|
|
|
if (
|
|
snap.src &&
|
|
ESCALATABLE_STATUSES.has(snap.status) &&
|
|
snap.attempts < RECOVERY.maxAttempts
|
|
) {
|
|
// Escalate directly to reload rung (skip nudge) -- increment attempts here.
|
|
const updated: EngineSnapshot = {
|
|
...snap,
|
|
attempts: snap.attempts + 1,
|
|
};
|
|
if (updated.attempts >= RECOVERY.maxAttempts) {
|
|
const msg =
|
|
code === 2 ? "Network error -- tap to retry" : message;
|
|
return {
|
|
snapshot: {
|
|
...updated,
|
|
status: "error",
|
|
error: { message: msg, code },
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-error" }],
|
|
};
|
|
}
|
|
const result = startReloadRung(updated, snap.currentTime, event.now);
|
|
return result;
|
|
}
|
|
|
|
const msg = code === 2 ? "Network error -- tap to retry" : message;
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "error",
|
|
error: { message: msg, code },
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-error" }],
|
|
};
|
|
}
|
|
|
|
case "tick": {
|
|
// Update hidden knowledge always.
|
|
let nextSnap: EngineSnapshot = {
|
|
...snap,
|
|
lastKnownHidden: event.hidden,
|
|
};
|
|
|
|
// If paused (defensive), do nothing further.
|
|
if (event.paused) {
|
|
return { snapshot: nextSnap, effects: [] };
|
|
}
|
|
|
|
// Ladder only runs in active statuses.
|
|
if (!LADDER_ACTIVE_STATUSES.has(snap.status)) {
|
|
return { snapshot: nextSnap, effects: [] };
|
|
}
|
|
|
|
const moved = event.currentTime !== snap.currentTime;
|
|
|
|
if (moved) {
|
|
nextSnap = {
|
|
...nextSnap,
|
|
currentTime: event.currentTime,
|
|
lastProgressAt: event.now,
|
|
progressStreakStartedAt:
|
|
nextSnap.progressStreakStartedAt ?? event.now,
|
|
};
|
|
|
|
// Budget reset after sustained progress.
|
|
if (
|
|
nextSnap.progressStreakStartedAt !== null &&
|
|
event.now - nextSnap.progressStreakStartedAt >=
|
|
RECOVERY.budgetResetAfterMs
|
|
) {
|
|
nextSnap = { ...nextSnap, attempts: 0 };
|
|
}
|
|
|
|
// If buffering/recovering and now moving with readyState >= 3 -> playing.
|
|
if (
|
|
(snap.status === "buffering" || snap.status === "recovering") &&
|
|
event.readyState >= 3
|
|
) {
|
|
nextSnap = {
|
|
...nextSnap,
|
|
status: "playing",
|
|
rung: "none",
|
|
};
|
|
return { snapshot: nextSnap, effects: cancelBothTimers() };
|
|
}
|
|
|
|
return { snapshot: nextSnap, effects: [] };
|
|
}
|
|
|
|
// No progress -- clear streak.
|
|
nextSnap = {
|
|
...nextSnap,
|
|
currentTime: event.currentTime,
|
|
progressStreakStartedAt: null,
|
|
};
|
|
|
|
// Classify and escalate.
|
|
const timeSinceProgress = event.now - snap.lastProgressAt;
|
|
|
|
if (event.readyState < 3) {
|
|
// Starved -- buffering deadline.
|
|
if (timeSinceProgress >= RECOVERY.bufferingDeadlineMs) {
|
|
nextSnap = { ...nextSnap, status: "buffering" };
|
|
const result = escalate(nextSnap, event.now, false);
|
|
return result;
|
|
}
|
|
// Not at deadline yet -- just classify.
|
|
if (snap.status === "playing") {
|
|
nextSnap = { ...nextSnap, status: "buffering" };
|
|
}
|
|
return { snapshot: nextSnap, effects: [] };
|
|
}
|
|
|
|
// readyState >= 3: stall deadline.
|
|
if (timeSinceProgress >= RECOVERY.stallDeadlineMs) {
|
|
const result = escalate(nextSnap, event.now, false);
|
|
return result;
|
|
}
|
|
|
|
return { snapshot: nextSnap, effects: [] };
|
|
}
|
|
|
|
case "timer-fired": {
|
|
if (event.timer === "nudge-settle") {
|
|
// Nudge wait expired without progress -- go to reload rung.
|
|
if (snap.rung !== "nudge-wait") {
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
// Check if we've hit max attempts (we incremented at nudge start).
|
|
if (snap.attempts >= RECOVERY.maxAttempts) {
|
|
return enterError(snap, "Playback stalled repeatedly", 99);
|
|
}
|
|
const result = startReloadRung(snap, snap.currentTime, event.now);
|
|
return result;
|
|
}
|
|
|
|
if (event.timer === "reload-settle") {
|
|
// Reload settle expired without canplay/progress.
|
|
if (snap.rung !== "reload-wait") {
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
// Try next attempt (nudge first).
|
|
if (snap.attempts >= RECOVERY.maxAttempts) {
|
|
return enterError(snap, "Playback stalled repeatedly", 99);
|
|
}
|
|
// Start nudge rung for next cycle.
|
|
const result = startNudgeRung(snap, event.now);
|
|
return result;
|
|
}
|
|
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
|
|
case "play-rejected": {
|
|
// Stale generation -- ignore entirely.
|
|
if (event.generation !== snap.generation) {
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
|
|
if (event.reason === "not-allowed") {
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "blocked",
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: cancelBothTimers(),
|
|
};
|
|
}
|
|
|
|
if (event.reason === "abort") {
|
|
// Genuine iOS bad-state -- single reload rung attempt, unless already recovering.
|
|
if (snap.status === "recovering") {
|
|
return { snapshot: snap, effects: [] };
|
|
}
|
|
if (snap.src && snap.attempts < RECOVERY.maxAttempts) {
|
|
const updated = { ...snap, attempts: snap.attempts + 1 };
|
|
if (updated.attempts >= RECOVERY.maxAttempts) {
|
|
return enterError(
|
|
updated,
|
|
"Playback stalled repeatedly",
|
|
99,
|
|
);
|
|
}
|
|
const result = startReloadRung(updated, snap.currentTime, event.now);
|
|
return result;
|
|
}
|
|
return enterError(snap, "Playback stalled repeatedly", 99);
|
|
}
|
|
|
|
// "other" -- terminal error.
|
|
return {
|
|
snapshot: {
|
|
...snap,
|
|
status: "error",
|
|
error: { message: "Playback failed", code: 0 },
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
},
|
|
effects: [...cancelBothTimers(), { kind: "emit-error" }],
|
|
};
|
|
}
|
|
|
|
case "foreground": {
|
|
const effects: Effect[] = [{ kind: "claim-session" }];
|
|
let nextSnap = snap;
|
|
|
|
if (snap.resumeOnForeground && snap.intent === "play") {
|
|
// One-tap affordance -- surface blocked, never call-play automatically.
|
|
nextSnap = {
|
|
...snap,
|
|
status: "blocked",
|
|
resumeOnForeground: false,
|
|
rung: "none",
|
|
progressStreakStartedAt: null,
|
|
};
|
|
}
|
|
|
|
return { snapshot: nextSnap, effects };
|
|
}
|
|
|
|
case "cleanup": {
|
|
return {
|
|
snapshot: initialSnapshot(),
|
|
effects: cancelBothTimers(),
|
|
};
|
|
}
|
|
}
|
|
}
|