diff --git a/src/components/auth/AccountSettings.tsx b/src/components/auth/AccountSettings.tsx index 98f6442a..62c6a8f1 100644 --- a/src/components/auth/AccountSettings.tsx +++ b/src/components/auth/AccountSettings.tsx @@ -102,7 +102,7 @@ export function AccountSettings({ submittedForm.current = event.currentTarget; void run("email", async () => { const updated = await requestAccountEmailChange(email); - return updated.email === email.trim() + return updated.email?.toLowerCase() === email.trim().toLowerCase() ? t("settings.account.emailUpdated", "Email address updated.") : t( "settings.account.confirmInboxes", diff --git a/src/components/auth/RecordingPermissionsSettings.tsx b/src/components/auth/RecordingPermissionsSettings.tsx index 792274f4..4745e5ff 100644 --- a/src/components/auth/RecordingPermissionsSettings.tsx +++ b/src/components/auth/RecordingPermissionsSettings.tsx @@ -17,71 +17,85 @@ export function RecordingPermissionsSettings({ onReview }: { onReview?: () => vo const { t } = useI18n(); const mounted = useRef(false); const pending = useRef(false); + const refreshQueued = useRef(false); const [snapshot, setSnapshot] = useState(); const [busy, setBusy] = useState(false); const [opening, setOpening] = useState(); const [error, setError] = useState(); const [actionError, setActionError] = useState<{ permission: Permission; message: string }>(); const refresh = useCallback(async () => { - if (pending.current) return; + if (!mounted.current) return; + if (pending.current) { + refreshQueued.current = true; + return; + } pending.current = true; setBusy(true); try { - const mac = (await window.electronAPI.getPlatform()) === "darwin"; - const values: PermissionSnapshot["values"] = {}; - let failed = false; - const results = await Promise.allSettled([ - mac - ? window.electronAPI.getScreenRecordingPermissionStatus() - : Promise.resolve(null), - mac ? window.electronAPI.getAccessibilityPermissionStatus() : Promise.resolve(null), - typeof window.electronAPI.getMediaPermissionStatus === "function" - ? window.electronAPI.getMediaPermissionStatus() - : Promise.resolve(null), - ]); - const [screen, accessibility, media] = results; - if (mac) { - if (screen.status === "fulfilled" && screen.value?.success) - values.screen = normalize(screen.value.status); - else { - values.screen = "unknown"; - failed = true; + do { + refreshQueued.current = false; + try { + const mac = (await window.electronAPI.getPlatform()) === "darwin"; + const values: PermissionSnapshot["values"] = {}; + let failed = false; + const results = await Promise.allSettled([ + mac + ? window.electronAPI.getScreenRecordingPermissionStatus() + : Promise.resolve(null), + mac + ? window.electronAPI.getAccessibilityPermissionStatus() + : Promise.resolve(null), + typeof window.electronAPI.getMediaPermissionStatus === "function" + ? window.electronAPI.getMediaPermissionStatus() + : Promise.resolve(null), + ]); + const [screen, accessibility, media] = results; + if (mac) { + if (screen.status === "fulfilled" && screen.value?.success) + values.screen = normalize(screen.value.status); + else { + values.screen = "unknown"; + failed = true; + } + if (accessibility.status === "fulfilled" && accessibility.value?.success) + values.accessibility = accessibility.value.trusted + ? "granted" + : "denied"; + else { + values.accessibility = "unknown"; + failed = true; + } + } + if (media.status === "fulfilled" && media.value?.success) { + values.microphone = normalize(media.value.microphone); + values.camera = normalize(media.value.camera); + } else { + values.microphone = "unknown"; + values.camera = "unknown"; + failed = true; + } + if (!mounted.current) return; + setSnapshot({ mac, values }); + setError( + failed + ? t( + "settings.permissions.checkFailed", + "Some permissions could not be checked. Try again.", + ) + : undefined, + ); + } catch { + if (mounted.current) { + setSnapshot(undefined); + setError( + t( + "settings.permissions.checkFailed", + "Some permissions could not be checked. Try again.", + ), + ); + } } - if (accessibility.status === "fulfilled" && accessibility.value?.success) - values.accessibility = accessibility.value.trusted ? "granted" : "denied"; - else { - values.accessibility = "unknown"; - failed = true; - } - } - if (media.status === "fulfilled" && media.value?.success) { - values.microphone = normalize(media.value.microphone); - values.camera = normalize(media.value.camera); - } else { - values.microphone = "unknown"; - values.camera = "unknown"; - failed = true; - } - if (!mounted.current) return; - setSnapshot({ mac, values }); - setError( - failed - ? t( - "settings.permissions.checkFailed", - "Some permissions could not be checked. Try again.", - ) - : undefined, - ); - } catch { - if (mounted.current) { - setSnapshot(undefined); - setError( - t( - "settings.permissions.checkFailed", - "Some permissions could not be checked. Try again.", - ), - ); - } + } while (mounted.current && refreshQueued.current); } finally { pending.current = false; if (mounted.current) setBusy(false); diff --git a/src/components/video-editor/GradientPicker.tsx b/src/components/video-editor/GradientPicker.tsx index e3b73058..62864aa6 100644 --- a/src/components/video-editor/GradientPicker.tsx +++ b/src/components/video-editor/GradientPicker.tsx @@ -144,8 +144,7 @@ export function GradientPicker({ {draft.colors.map((color, index) => (
- {te("color", "Color")} - {index + 1} + {te("colorNumber", "Color {{number}}", { number: index + 1 })}
{ } }); }); + +const gradientLocale = vi.hoisted(() => ({ value: "en" as AppLocale })); +vi.mock("react", async (original) => ({ + ...(await original()), + useEffect: () => undefined, + useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], +})); +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: + (namespace: string) => + (key: string, fallback?: string, values?: Record) => + translateForLocale(gradientLocale.value, `${namespace}.${key}`, fallback, values), +})); + +function gradientNodes(node: ReactNode): ReactElement>[] { + if (Array.isArray(node)) return node.flatMap(gradientNodes); + if (!node || typeof node !== "object") return []; + const element = node as ReactElement<{ children?: ReactNode }>; + return [element, ...gradientNodes(element.props?.children)]; +} +function gradientText(node: ReactNode): string { + if (Array.isArray(node)) return node.map(gradientText).join(""); + if (node == null || typeof node === "boolean") return ""; + if (typeof node !== "object") return String(node); + return gradientText((node as ReactElement<{ children?: ReactNode }>).props.children); +} + +it.each( + SUPPORTED_LOCALES, +)("uses the localized numbered color label consistently in %s", (locale) => { + gradientLocale.value = locale; + const tree = GradientPicker({ + value: "#ffffff", + presets: [], + onChange: vi.fn(), + tileClassName: () => "", + open: true, + }); + const elements = gradientNodes(tree); + const rowLabels = elements.filter( + (node) => node.type === "span" && node.props.className === "text-sm text-muted", + ); + const controls = elements.filter((node) => node.type === ColorControl); + expect(rowLabels).toHaveLength(2); + expect(controls).toHaveLength(2); + for (const [index, row] of rowLabels.entries()) { + const expected = translateForLocale(locale, "editing.colorNumber", undefined, { + number: index + 1, + }); + expect(expected).not.toBe("editing.colorNumber"); + expect(gradientText(row.props.children as ReactNode)).toBe(expected); + expect(controls[index].props.label).toBe(expected); + } +}); diff --git a/src/components/video-editor/videoPlayback/usePreviewMediaState.ts b/src/components/video-editor/videoPlayback/usePreviewMediaState.ts index 97695ab9..97597eb3 100644 --- a/src/components/video-editor/videoPlayback/usePreviewMediaState.ts +++ b/src/components/video-editor/videoPlayback/usePreviewMediaState.ts @@ -16,8 +16,13 @@ export function usePreviewMediaState( const videoRef = useRef(null); const previewVideoSourceRef = useRef(new PreviewVideoSource()); + const previewRenderLoopRef = useRef | null>(null); const attachVideo = useCallback((video: HTMLVideoElement | null) => { + if (video !== videoRef.current) { + // Ref detachment precedes passive cleanup. Stop sampling before the source is destroyed. + previewRenderLoopRef.current?.dispose(); + } // VideoSource.destroy() clears the media URL, so only destroy it when // React detaches the element, never during a layout effect cleanup. previewVideoSourceRef.current.setVideo(video); @@ -64,7 +69,6 @@ export function usePreviewMediaState( const lastRenderedContentTimeRef = useRef(null); - const previewRenderLoopRef = useRef | null>(null); const requestPreviewRender = useCallback(() => { previewRenderLoopRef.current?.requestRender(); }, []); diff --git a/src/hooks/recordingFileWriter.ts b/src/hooks/recordingFileWriter.ts index 1a7769cc..c4b14f88 100644 --- a/src/hooks/recordingFileWriter.ts +++ b/src/hooks/recordingFileWriter.ts @@ -12,6 +12,7 @@ export class RecordingFileWriter { private queue = Promise.resolve(); private streamId: string | undefined; private pendingBytes = 0; + private oversizedChunkBytes = 0; private failure: string | undefined; private cancelled = false; private path: string | undefined; @@ -28,8 +29,12 @@ export class RecordingFileWriter { } write(chunk: Blob) { if (this.cancelled || this.failure || !chunk.size) return; - // A single delayed recorder Blob drains in slices; bound additional queued data. - if (this.pendingBytes > 0 && this.pendingBytes + chunk.size > 32 * 1024 * 1024) { + const isOversizedChunk = this.pendingBytes === 0 && chunk.size > 32 * 1024 * 1024; + // One delayed Blob may drain in slices alongside up to 32 MiB of later data. + if ( + !isOversizedChunk && + this.pendingBytes - this.oversizedChunkBytes + chunk.size > 32 * 1024 * 1024 + ) { this.fail( new Error( "Recording storage cannot keep up. The partial recording will be preserved.", @@ -37,6 +42,7 @@ export class RecordingFileWriter { ); return; } + if (isOversizedChunk) this.oversizedChunkBytes = chunk.size; this.pendingBytes += chunk.size; this.queue = this.queue .then(async () => { @@ -68,6 +74,7 @@ export class RecordingFileWriter { .catch((error) => this.fail(error)) .finally(() => { this.pendingBytes -= chunk.size; + if (isOversizedChunk) this.oversizedChunkBytes = 0; }); } cancel() {