Guard playback speed initialization

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-29 12:42:00 -04:00
committed by John Tennant
parent eec0a41312
commit 3111425ea8
3 changed files with 54 additions and 7 deletions
@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { getTtsPlaybackSpeed, setTtsPlaybackSpeed } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { commitPlaybackSpeed } from "./playbackSpeedPersistence";
import {
applyLoadedPlaybackSpeed,
commitPlaybackSpeed,
} from "./playbackSpeedPersistence";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
const DEFAULT_SPEED = 1;
@@ -12,22 +15,34 @@ const SPEED_STEP = 0.05;
export function SpeechPlaybackSettings() {
const [speed, setSpeed] = useState(DEFAULT_SPEED);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const confirmedSpeed = useRef(DEFAULT_SPEED);
const desiredSpeed = useRef(DEFAULT_SPEED);
const flushPromise = useRef<Promise<void> | null>(null);
const hasLocalIntent = useRef(false);
useEffect(() => {
let active = true;
getTtsPlaybackSpeed()
.then((savedSpeed) => {
if (active) {
confirmedSpeed.current = savedSpeed;
desiredSpeed.current = savedSpeed;
setSpeed(savedSpeed);
applyLoadedPlaybackSpeed(
{
confirmedSpeed,
desiredSpeed,
flushPromise,
hasLocalIntent,
},
{ setSpeed },
savedSpeed,
);
}
})
.catch((cause) => {
if (active) setError(String(cause));
})
.finally(() => {
if (active) setLoaded(true);
});
return () => {
active = false;
@@ -36,7 +51,7 @@ export function SpeechPlaybackSettings() {
const commitSpeed = (nextSpeed: number) => {
commitPlaybackSpeed(
{ confirmedSpeed, desiredSpeed, flushPromise },
{ confirmedSpeed, desiredSpeed, flushPromise, hasLocalIntent },
{ persist: setTtsPlaybackSpeed, setError, setSpeed },
nextSpeed,
);
@@ -66,7 +81,7 @@ export function SpeechPlaybackSettings() {
{speed.toFixed(2)}x
</span>
<Button
disabled={speed === DEFAULT_SPEED}
disabled={!loaded || speed === DEFAULT_SPEED}
onClick={() => commitSpeed(DEFAULT_SPEED)}
size="sm"
type="button"
@@ -79,6 +94,7 @@ export function SpeechPlaybackSettings() {
<input
aria-valuetext={`${speed.toFixed(2)} times`}
className="mt-3 w-full accent-primary"
disabled={!loaded}
id="speech-playback-speed"
max={MAX_SPEED}
min={MIN_SPEED}
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { commitPlaybackSpeed } from "./playbackSpeedPersistence.ts";
import {
applyLoadedPlaybackSpeed,
commitPlaybackSpeed,
} from "./playbackSpeedPersistence.ts";
function deferred() {
let resolve;
@@ -22,6 +25,7 @@ function harness() {
confirmedSpeed: { current: 1 },
desiredSpeed: { current: 1 },
flushPromise: { current: null },
hasLocalIntent: { current: false },
};
const callbacks = {
persist: (speed) => {
@@ -86,3 +90,17 @@ test("the latest failure rolls back to the last confirmed speed", async () => {
assert.deepEqual(h.speeds, [1.25, 1]);
assert.equal(h.errors.at(-1), "Error: save failed");
});
test("a delayed initial load cannot overwrite a local intent", async () => {
const h = harness();
commitPlaybackSpeed(h.state, h.callbacks, 1.5);
applyLoadedPlaybackSpeed(h.state, h.callbacks, 1);
assert.equal(h.state.desiredSpeed.current, 1.5);
assert.deepEqual(h.speeds, [1.5]);
h.saves[0].resolve();
await settle(h.state);
assert.equal(h.state.confirmedSpeed.current, 1.5);
assert.equal(h.state.desiredSpeed.current, 1.5);
});
@@ -6,6 +6,7 @@ export type PlaybackSpeedPersistenceState = {
confirmedSpeed: MutableValue<number>;
desiredSpeed: MutableValue<number>;
flushPromise: MutableValue<Promise<void> | null>;
hasLocalIntent: MutableValue<boolean>;
};
type PlaybackSpeedPersistenceCallbacks = {
@@ -19,12 +20,24 @@ export function commitPlaybackSpeed(
callbacks: PlaybackSpeedPersistenceCallbacks,
nextSpeed: number,
) {
state.hasLocalIntent.current = true;
state.desiredSpeed.current = nextSpeed;
callbacks.setSpeed(nextSpeed);
callbacks.setError(null);
ensurePlaybackSpeedFlush(state, callbacks);
}
export function applyLoadedPlaybackSpeed(
state: PlaybackSpeedPersistenceState,
callbacks: Pick<PlaybackSpeedPersistenceCallbacks, "setSpeed">,
savedSpeed: number,
) {
if (state.hasLocalIntent.current) return;
state.confirmedSpeed.current = savedSpeed;
state.desiredSpeed.current = savedSpeed;
callbacks.setSpeed(savedSpeed);
}
function ensurePlaybackSpeedFlush(
state: PlaybackSpeedPersistenceState,
callbacks: PlaybackSpeedPersistenceCallbacks,