Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

199 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiFetch, apiUrl } from "@/lib/api";
// Three-state autoplay model:
// • global default — persisted per-user (Settings Voice → ui.voice_autoplay)
// • session override — sessionStorage, wins over the global default
// • first-play prompt — when the global default is off and the user manually
// plays one reply, we offer to auto-play the rest of the session.
const SESSION_KEY_PREFIX = "deeptutor.voiceAutoplay.session"; // "on" | "off"
const PROMPTED_KEY_PREFIX = "deeptutor.voiceAutoplay.prompted"; // "1"
const GLOBAL_EVENT = "deeptutor:voice-autoplay-global";
const SESSION_EVENT = "deeptutor:voice-autoplay-session";
let cachedGlobal: boolean | null = null;
let inflight: Promise<boolean> | null = null;
function fetchGlobalDefault(): Promise<boolean> {
if (cachedGlobal !== null) return Promise.resolve(cachedGlobal);
if (!inflight) {
inflight = apiFetch(apiUrl("/api/v1/settings"))
.then((r) => (r.ok ? r.json() : null))
.then((payload) => {
cachedGlobal = Boolean(payload?.ui?.voice_autoplay);
return cachedGlobal;
})
.catch(() => {
cachedGlobal = false;
return false;
})
.finally(() => {
inflight = null;
});
}
return inflight;
}
function scopedKey(prefix: string, scopeKey?: string): string {
return `${prefix}:${scopeKey || "default"}`;
}
function readSession(scopeKey?: string): boolean | null {
if (typeof window === "undefined") return null;
try {
const v = window.sessionStorage.getItem(
scopedKey(SESSION_KEY_PREFIX, scopeKey),
);
return v === "on" ? true : v === "off" ? false : null;
} catch {
return null;
}
}
function writeSession(value: boolean, scopeKey?: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
scopedKey(SESSION_KEY_PREFIX, scopeKey),
value ? "on" : "off",
);
window.dispatchEvent(
new CustomEvent(SESSION_EVENT, { detail: { scopeKey, value } }),
);
} catch {
// sessionStorage may be unavailable
}
}
/**
* Settings-page hook: read/write the persisted global default.
*/
export function useVoiceAutoplayPreference() {
const [value, setVal] = useState<boolean>(cachedGlobal ?? false);
const [loading, setLoading] = useState<boolean>(cachedGlobal === null);
useEffect(() => {
let active = true;
fetchGlobalDefault().then((v) => {
if (active) {
setVal(v);
setLoading(false);
}
});
return () => {
active = false;
};
}, []);
const setValue = useCallback(async (next: boolean) => {
setVal(next);
cachedGlobal = next;
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(GLOBAL_EVENT, { detail: { value: next } }),
);
}
await apiFetch(apiUrl("/api/v1/settings/voice-autoplay"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice_autoplay: next }),
});
}, []);
return { value, setValue, loading };
}
/**
* Chat-surface hook: the effective autoplay flag plus the session controls
* and the first-play prompt gate.
*/
export function useVoiceAutoplay(scopeKey?: string) {
const normalizedScopeKey = scopeKey || undefined;
const [globalDefault, setGlobalDefault] = useState<boolean>(
cachedGlobal ?? false,
);
const [stateScopeKey, setStateScopeKey] = useState<string | undefined>(
normalizedScopeKey,
);
const [sessionOverride, setSessionOverride] = useState<boolean | null>(() =>
readSession(normalizedScopeKey),
);
if (stateScopeKey !== normalizedScopeKey) {
setStateScopeKey(normalizedScopeKey);
setSessionOverride(readSession(normalizedScopeKey));
}
useEffect(() => {
let active = true;
fetchGlobalDefault().then((v) => active && setGlobalDefault(v));
const onGlobal = (e: Event) =>
setGlobalDefault(Boolean((e as CustomEvent).detail?.value));
const onSession = (e: Event) => {
const detail = (e as CustomEvent).detail as
| { scopeKey?: string; value?: boolean }
| undefined;
if ((detail?.scopeKey || undefined) !== normalizedScopeKey) return;
setSessionOverride(Boolean(detail?.value));
};
window.addEventListener(GLOBAL_EVENT, onGlobal);
window.addEventListener(SESSION_EVENT, onSession);
return () => {
active = false;
window.removeEventListener(GLOBAL_EVENT, onGlobal);
window.removeEventListener(SESSION_EVENT, onSession);
};
}, [normalizedScopeKey]);
const autoplayEnabled = sessionOverride ?? globalDefault;
const enableForSession = useCallback(() => {
writeSession(true, normalizedScopeKey);
setSessionOverride(true);
}, [normalizedScopeKey]);
const disableForSession = useCallback(() => {
writeSession(false, normalizedScopeKey);
setSessionOverride(false);
}, [normalizedScopeKey]);
const markPrompted = useCallback(() => {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
scopedKey(PROMPTED_KEY_PREFIX, normalizedScopeKey),
"1",
);
} catch {
// ignore
}
}, [normalizedScopeKey]);
// Offer the "auto-play this session?" prompt only when autoplay isn't already
// on and we haven't asked yet this session.
const shouldPromptOnFirstPlay = useCallback((): boolean => {
if (autoplayEnabled) return false;
if (sessionOverride !== null) return false;
if (typeof window === "undefined") return false;
try {
return (
window.sessionStorage.getItem(
scopedKey(PROMPTED_KEY_PREFIX, normalizedScopeKey),
) !== "1"
);
} catch {
return false;
}
}, [autoplayEnabled, normalizedScopeKey, sessionOverride]);
return {
autoplayEnabled,
enableForSession,
disableForSession,
markPrompted,
shouldPromptOnFirstPlay,
};
}