From 5bbe32c870bc06e35e5064f3c8403ff22b359d32 Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:41:29 +0800 Subject: [PATCH 1/2] feat: degrade Stream by capability --- src-tauri/src/commands/stream.rs | 89 +- src-tauri/src/problem.rs | 15 + src/api/commandClient.dispatch.test.ts | 19 + src/api/problems.ts | 5 +- .../stream/streamCapabilityState.test.ts | 250 ++++++ src/features/stream/streamPresentation.ts | 81 ++ src/features/stream/streamProblems.ts | 114 +++ src/features/stream/streamWorkflow.test.ts | 320 ++----- src/features/stream/streamWorkflow.ts | 816 +++++++++++------- src/features/stream/useStreamPage.ts | 23 +- src/i18n/messages.ts | 46 + src/pages/Stream.tsx | 196 ++++- src/types/generated/commands.ts | 2 +- 13 files changed, 1368 insertions(+), 608 deletions(-) create mode 100644 src/features/stream/streamCapabilityState.test.ts create mode 100644 src/features/stream/streamPresentation.ts create mode 100644 src/features/stream/streamProblems.ts diff --git a/src-tauri/src/commands/stream.rs b/src-tauri/src/commands/stream.rs index b789226..1f7cd54 100644 --- a/src-tauri/src/commands/stream.rs +++ b/src-tauri/src/commands/stream.rs @@ -1,3 +1,4 @@ +use crate::problem::{SemanticProblem, SemanticProblemCode}; use crate::services::path::normalize_requested_game_path; use crate::stream::{ overlay_settings::{ @@ -12,7 +13,7 @@ use crate::stream::{ #[specta::specta] pub fn get_stream_status( runtime: tauri::State<'_, StreamRuntime>, -) -> Result { +) -> Result { Ok(runtime.snapshot()) } @@ -22,10 +23,11 @@ pub async fn ensure_stream_session( app: tauri::AppHandle, runtime: tauri::State<'_, StreamRuntime>, game_path: Option, -) -> Result { +) -> Result { runtime .ensure(app, normalize_requested_game_path(game_path)) .await + .map_err(|diagnostic| stream_service_problem("ensure", diagnostic)) } #[tauri::command] @@ -34,10 +36,11 @@ pub async fn restart_stream_session( app: tauri::AppHandle, runtime: tauri::State<'_, StreamRuntime>, game_path: Option, -) -> Result { +) -> Result { runtime .restart(app, normalize_requested_game_path(game_path)) .await + .map_err(|diagnostic| stream_service_problem("restart", diagnostic)) } #[tauri::command] @@ -45,32 +48,92 @@ pub async fn restart_stream_session( pub async fn set_stream_window( runtime: tauri::State<'_, StreamRuntime>, offset: usize, -) -> Result { - runtime.set_window(offset).await +) -> Result { + runtime + .set_window(offset) + .await + .map_err(|diagnostic| stream_window_problem(offset, diagnostic)) } #[tauri::command] #[specta::specta] -pub fn get_overlay_settings() -> Result { - OverlaySettingsStore::default().load_payload() +pub fn get_overlay_settings() -> Result { + OverlaySettingsStore::default() + .load_payload() + .map_err(|diagnostic| stream_crop_problem("load", diagnostic)) } #[tauri::command] #[specta::specta] -pub fn apply_overlay_crop_code(code: String) -> Result { - OverlaySettingsStore::default().import_code(&code) +pub fn apply_overlay_crop_code( + code: String, +) -> Result { + OverlaySettingsStore::default() + .import_code(&code) + .map_err(|diagnostic| stream_crop_problem("apply_code", diagnostic)) } #[tauri::command] #[specta::specta] pub fn save_overlay_display_mode( display_mode: StreamOverlayDisplayMode, -) -> Result { - OverlaySettingsStore::default().save_display_mode(display_mode) +) -> Result { + OverlaySettingsStore::default() + .save_display_mode(display_mode) + .map_err(|diagnostic| stream_crop_problem("save_display_mode", diagnostic)) } #[tauri::command] #[specta::specta] -pub fn reset_overlay_crop() -> Result { - OverlaySettingsStore::default().save(OverlayCropSettings::default()) +pub fn reset_overlay_crop() -> Result { + OverlaySettingsStore::default() + .save(OverlayCropSettings::default()) + .map_err(|diagnostic| stream_crop_problem("reset", diagnostic)) +} + +fn stream_service_problem(operation: &str, diagnostic: String) -> SemanticProblem { + SemanticProblem::new(SemanticProblemCode::StreamServiceFailed) + .with_param("operation", operation) + .with_diagnostic(diagnostic) +} + +fn stream_window_problem(offset: usize, diagnostic: String) -> SemanticProblem { + SemanticProblem::new(SemanticProblemCode::StreamWindowFailed) + .with_param("operation", "set_window") + .with_param("offset", offset.to_string()) + .with_diagnostic(diagnostic) +} + +fn stream_crop_problem(operation: &str, diagnostic: String) -> SemanticProblem { + SemanticProblem::new(SemanticProblemCode::StreamCropFailed) + .with_param("operation", operation) + .with_diagnostic(diagnostic) +} + +#[cfg(test)] +mod tests { + use super::{stream_crop_problem, stream_service_problem, stream_window_problem}; + use crate::problem::SemanticProblemCode; + + #[test] + fn stream_command_failures_keep_capability_operation_and_diagnostic() { + let service = stream_service_problem("restart", "port occupied".to_string()); + assert_eq!(service.code, SemanticProblemCode::StreamServiceFailed); + assert_eq!( + service.params.get("operation").map(String::as_str), + Some("restart") + ); + assert_eq!(service.diagnostic.as_deref(), Some("port occupied")); + + let window = stream_window_problem(3, "record missing".to_string()); + assert_eq!(window.code, SemanticProblemCode::StreamWindowFailed); + assert_eq!(window.params.get("offset").map(String::as_str), Some("3")); + + let crop = stream_crop_problem("apply_code", "invalid code".to_string()); + assert_eq!(crop.code, SemanticProblemCode::StreamCropFailed); + assert_eq!( + crop.params.get("operation").map(String::as_str), + Some("apply_code") + ); + } } diff --git a/src-tauri/src/problem.rs b/src-tauri/src/problem.rs index ed453da..0b5b7fd 100644 --- a/src-tauri/src/problem.rs +++ b/src-tauri/src/problem.rs @@ -17,6 +17,9 @@ pub enum SemanticProblemCode { InstallActionFailed, InstallGameRunning, InstallPartialFailure, + StreamServiceFailed, + StreamWindowFailed, + StreamCropFailed, } impl SemanticProblem { @@ -81,5 +84,17 @@ mod tests { "diagnostic": null }) ); + + assert_eq!( + serde_json::to_value(SemanticProblem::new( + SemanticProblemCode::StreamServiceFailed + )) + .unwrap(), + serde_json::json!({ + "code": "stream_service_failed", + "params": {}, + "diagnostic": null + }) + ); } } diff --git a/src/api/commandClient.dispatch.test.ts b/src/api/commandClient.dispatch.test.ts index dbda6dd..4093872 100644 --- a/src/api/commandClient.dispatch.test.ts +++ b/src/api/commandClient.dispatch.test.ts @@ -74,6 +74,25 @@ describe('native command adapter', () => { problem }); }); + + it('preserves Stream capability and operation failures', async () => { + vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + const problem = { + code: 'stream_crop_failed', + params: { operation: 'apply_code' }, + diagnostic: 'invalid crop payload' + }; + invokeMock.mockRejectedValueOnce(problem); + const { commandClient } = await import('./commandClient'); + + await expect( + commandClient.applyOverlayCropCode('bad') + ).rejects.toMatchObject({ + name: 'SemanticProblemError', + message: 'stream_crop_failed', + problem + }); + }); }); describe('normalizeBackendError sentinel contract', () => { diff --git a/src/api/problems.ts b/src/api/problems.ts index 35dd61f..23c122e 100644 --- a/src/api/problems.ts +++ b/src/api/problems.ts @@ -7,7 +7,10 @@ const semanticProblemCodes: Record = { install_detection_failed: true, install_action_failed: true, install_game_running: true, - install_partial_failure: true + install_partial_failure: true, + stream_service_failed: true, + stream_window_failed: true, + stream_crop_failed: true }; export class SemanticProblemError extends Error { diff --git a/src/features/stream/streamCapabilityState.test.ts b/src/features/stream/streamCapabilityState.test.ts new file mode 100644 index 0000000..121c617 --- /dev/null +++ b/src/features/stream/streamCapabilityState.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from 'vitest'; +import { formatMessage } from '../../i18n/messages'; +import { + defaultCropSettings, + idleStreamStatus +} from '../../api/previewDefaults'; +import type { + StreamOverlayCropSettingsPayload, + StreamServiceStatus +} from '../../types/backend'; +import { + createStreamWorkflow, + type StreamCommandPort, + type StreamScheduler +} from './streamWorkflow'; +import { + presentStreamProblem, + presentStreamSnapshot +} from './streamPresentation'; + +function runningStatus( + overrides: Partial = {} +): StreamServiceStatus { + return { + ...idleStreamStatus, + running: true, + port: 17654, + base_url: 'http://127.0.0.1:17654', + overlay_url: 'http://127.0.0.1:17654/overlay', + settings_url: 'http://127.0.0.1:17654/settings', + db: { found: true, path: '/game/BazaarPlusPlusV4/bazaarplusplus.db' }, + ...overrides + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +class FakeScheduler implements StreamScheduler { + private nextId = 1; + readonly intervals = new Map void>(); + readonly timeouts = new Map void>(); + + setInterval(callback: () => void) { + const id = this.nextId++; + this.intervals.set(id, callback); + return id; + } + + clearInterval(handle: unknown) { + this.intervals.delete(handle as number); + } + + setTimeout(callback: () => void) { + const id = this.nextId++; + this.timeouts.set(id, callback); + return id; + } + + clearTimeout(handle: unknown) { + this.timeouts.delete(handle as number); + } + + fireIntervals() { + for (const callback of [...this.intervals.values()]) callback(); + } +} + +function commands( + overrides: Partial = {} +): StreamCommandPort { + return { + ensureSession: vi.fn().mockResolvedValue(runningStatus()), + getStatus: vi.fn().mockResolvedValue(runningStatus()), + restartSession: vi.fn().mockResolvedValue(runningStatus()), + setWindow: vi.fn().mockResolvedValue(runningStatus()), + loadCropSettings: vi.fn().mockResolvedValue(defaultCropSettings), + applyCropCode: vi.fn().mockResolvedValue(defaultCropSettings), + saveDisplayMode: vi.fn().mockResolvedValue(defaultCropSettings), + resetCropSettings: vi.fn().mockResolvedValue(defaultCropSettings), + ...overrides + }; +} + +function setup(commandOverrides: Partial = {}) { + const scheduler = new FakeScheduler(); + const commandPort = commands(commandOverrides); + const workflow = createStreamWorkflow({ + commands: commandPort, + scheduler, + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + opener: { open: vi.fn().mockResolvedValue(undefined) } + }); + return { workflow, scheduler, commands: commandPort }; +} + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('Stream capability state', () => { + it('publishes crop availability while service initialization is still loading', async () => { + const status = deferred(); + const { workflow } = setup({ ensureSession: () => status.promise }); + + const start = workflow.start(); + await flush(); + + expect(workflow.getSnapshot().service.phase).toBe('loading'); + expect(workflow.getSnapshot().crop.phase).toBe('available'); + expect(workflow.getSnapshot().crop.canEdit).toBe(true); + + status.resolve(runningStatus()); + await start; + }); + + it('keeps service and window controls usable when crop configuration degrades', async () => { + const { workflow } = setup({ + loadCropSettings: vi.fn().mockRejectedValue(new Error('crop unavailable')) + }); + + await workflow.start(); + const snapshot = workflow.getSnapshot(); + + expect(snapshot.service.phase).toBe('available'); + expect(snapshot.window.phase).toBe('available'); + expect(snapshot.crop.phase).toBe('degraded'); + expect(snapshot.crop.problem).toMatchObject({ + code: 'stream_crop_failed', + diagnostic: 'crop unavailable' + }); + expect(snapshot.service.canRestart).toBe(true); + expect(snapshot.window.canMoveMoreHistory).toBe(true); + expect(snapshot.crop.canEdit).toBe(true); + }); + + it('marks failed polling as stale without claiming the last running value is authoritative', async () => { + const getStatus = vi.fn().mockRejectedValue(new Error('poll unavailable')); + const { workflow, scheduler } = setup({ getStatus }); + await workflow.start(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + scheduler.fireIntervals(); + await flush(); + } + + const stale = workflow.getSnapshot(); + expect(stale.service.status?.running).toBe(true); + expect(stale.polling).toMatchObject({ + phase: 'degraded', + freshness: 'stale', + problem: { code: 'stream_poll_failed' } + }); + expect(stale.crop.canEdit).toBe(true); + + const zh = presentStreamSnapshot(stale, (key, params) => + formatMessage('zh', key, params) + ); + expect(zh.status.label).toBe(formatMessage('zh', 'streamStatusStale')); + expect(zh.status.detail).not.toBe( + formatMessage('zh', 'streamPortDetail', { port: 17654 }) + ); + + getStatus.mockResolvedValueOnce(runningStatus({ active_window_offset: 1 })); + expect(await workflow.intents.retryStatus()).toBe(true); + expect(workflow.getSnapshot().polling).toMatchObject({ + phase: 'available', + freshness: 'fresh', + problem: null + }); + }); + + it('scopes action failures and operation gates to their capabilities', async () => { + const pendingCrop = deferred(); + const { workflow } = setup({ + applyCropCode: () => pendingCrop.promise, + setWindow: vi.fn().mockRejectedValue(new Error('window failed')) + }); + await workflow.start(); + + const cropAction = workflow.intents.submitCropCode(); + expect(workflow.getSnapshot().crop.operation).toBe('crop'); + expect(workflow.getSnapshot().service.canRestart).toBe(true); + expect(workflow.getSnapshot().window.canMoveMoreHistory).toBe(true); + + pendingCrop.reject(new Error('crop failed')); + expect(await cropAction).toBe(false); + expect(workflow.getSnapshot().crop.problem?.code).toBe( + 'stream_crop_failed' + ); + + expect(await workflow.intents.moveWindow(1)).toBe(false); + expect(workflow.getSnapshot().window.problem).toMatchObject({ + code: 'stream_window_failed', + diagnostic: 'window failed' + }); + expect(workflow.getSnapshot().crop.canEdit).toBe(true); + expect(workflow.getSnapshot().service.problem).toBeNull(); + }); + + it('re-presents one live workflow in either locale without starting it again', async () => { + const ensureSession = vi.fn().mockResolvedValue(runningStatus()); + const { workflow } = setup({ ensureSession }); + await workflow.start(); + const snapshot = workflow.getSnapshot(); + + const zh = presentStreamSnapshot(snapshot, (key, params) => + formatMessage('zh', key, params) + ); + const en = presentStreamSnapshot(snapshot, (key, params) => + formatMessage('en', key, params) + ); + + expect(zh.status.label).not.toBe(en.status.label); + expect(ensureSession).toHaveBeenCalledTimes(1); + expect(workflow.getSnapshot()).toBe(snapshot); + }); +}); + +describe('Stream problem presentation', () => { + it.each([ + ['stream_service_failed', { operation: 'restart' }], + ['stream_poll_failed', { operation: 'poll_status' }], + ['stream_window_failed', { operation: 'set_window' }], + ['stream_crop_failed', { operation: 'apply_code' }], + ['stream_copy_failed', { operation: 'copy_obs_url' }], + ['stream_open_failed', { operation: 'open_overlay' }], + ['stream_unexpected', {}] + ] as const)('localizes %s with recovery copy', (code, params) => { + const problem = { code, params, diagnostic: 'native detail' }; + const zh = presentStreamProblem(problem, (key, values) => + formatMessage('zh', key, values) + ); + const en = presentStreamProblem(problem, (key, values) => + formatMessage('en', key, values) + ); + + expect(zh).not.toContain('native detail'); + expect(en).not.toContain('native detail'); + expect(zh).not.toBe(en); + }); +}); diff --git a/src/features/stream/streamPresentation.ts b/src/features/stream/streamPresentation.ts new file mode 100644 index 0000000..c6374e2 --- /dev/null +++ b/src/features/stream/streamPresentation.ts @@ -0,0 +1,81 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { StreamPageSnapshot } from './streamWorkflow'; +import { presentStreamNotice, presentStreamProblem } from './streamProblems'; + +export { presentStreamProblem }; + +export type StreamStatusPresentation = { + label: string; + detail: string; + tone: 'loading' | 'running' | 'idle' | 'degraded' | 'stale'; +}; + +export type StreamPagePresentation = { + status: StreamStatusPresentation; + dbLabel: string; + windowLabel: string; + notice: string | null; +}; + +export function presentStreamSnapshot( + snapshot: StreamPageSnapshot, + t: Translate +): StreamPagePresentation { + const status = snapshot.service.status; + let statusPresentation: StreamStatusPresentation; + + if (snapshot.service.phase === 'loading') { + statusPresentation = { + label: t('streamStatusStarting'), + detail: t('streamStarting'), + tone: 'loading' + }; + } else if (snapshot.service.problem) { + statusPresentation = { + label: t('streamStatusError'), + detail: presentStreamProblem(snapshot.service.problem, t), + tone: 'degraded' + }; + } else if (snapshot.polling.freshness === 'stale') { + statusPresentation = { + label: t('streamStatusStale'), + detail: status?.running + ? t('streamStaleRunningDetail') + : t('streamStaleIdleDetail'), + tone: 'stale' + }; + } else if (status?.running) { + statusPresentation = { + label: t('streamStatusRunning'), + detail: + status.port === null + ? t('streamStatusUnavailableDetail') + : t('streamPortDetail', { port: status.port }), + tone: 'running' + }; + } else if (status) { + statusPresentation = { + label: t('streamStatusIdle'), + detail: t('streamIdleDetail'), + tone: 'idle' + }; + } else { + statusPresentation = { + label: t('streamStatusUnavailable'), + detail: t('streamStatusUnavailableDetail'), + tone: 'degraded' + }; + } + + return { + status: statusPresentation, + dbLabel: status?.db.found ? t('dbConnected') : t('dbMissing'), + windowLabel: + (status?.active_window_offset ?? 0) === 0 + ? t('streamWindowLatest') + : t('streamWindowOffset', { + count: status?.active_window_offset ?? 0 + }), + notice: presentStreamNotice(snapshot.notice, t) + }; +} diff --git a/src/features/stream/streamProblems.ts b/src/features/stream/streamProblems.ts new file mode 100644 index 0000000..90fda89 --- /dev/null +++ b/src/features/stream/streamProblems.ts @@ -0,0 +1,114 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { MessageKey } from '../../i18n/messages'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type StreamProblemCode = + | 'stream_service_failed' + | 'stream_poll_failed' + | 'stream_window_failed' + | 'stream_crop_failed' + | 'stream_copy_failed' + | 'stream_open_failed' + | 'stream_unexpected'; + +export type StreamProblem = UiProblem; + +export type StreamNoticeCode = + | 'stream_obs_url_copied' + | 'stream_crop_saved' + | 'stream_crop_reset'; + +export type StreamNotice = { + code: StreamNoticeCode; + params: Record; +}; + +export function streamProblemFromError( + error: unknown, + fallbackCode: StreamProblemCode, + fallbackParams: Record = {} +): StreamProblem { + const problem: UiProblem = problemFromError(error, fallbackCode); + if (isStreamProblemCode(problem.code)) { + return { + ...problem, + code: problem.code, + params: { ...fallbackParams, ...problem.params } + }; + } + + return createUiProblem(fallbackCode, { + params: { ...fallbackParams, ...problem.params }, + diagnostic: problem.diagnostic + }); +} + +export function streamRuntimeProblem(diagnostic: string): StreamProblem { + return createUiProblem('stream_service_failed', { + params: { operation: 'runtime' }, + diagnostic + }); +} + +export function presentStreamProblem( + problem: StreamProblem, + t: Translate +): string { + return t(streamProblemMessageKey(problem), problem.params); +} + +export function presentStreamNotice( + notice: StreamNotice | null, + t: Translate +): string | null { + if (!notice) return null; + switch (notice.code) { + case 'stream_obs_url_copied': + return t('streamCopied', notice.params); + case 'stream_crop_saved': + return t('streamCropSaved', notice.params); + case 'stream_crop_reset': + return t('streamCropReset', notice.params); + } +} + +function isStreamProblemCode(code: string): code is StreamProblemCode { + return ( + code === 'stream_service_failed' || + code === 'stream_poll_failed' || + code === 'stream_window_failed' || + code === 'stream_crop_failed' || + code === 'stream_copy_failed' || + code === 'stream_open_failed' || + code === 'stream_unexpected' + ); +} + +function streamProblemMessageKey(problem: StreamProblem): MessageKey { + switch (problem.code) { + case 'stream_service_failed': + return problem.params.operation === 'restart' + ? 'streamProblemRestartFailed' + : 'streamProblemServiceFailed'; + case 'stream_poll_failed': + return 'streamProblemPollFailed'; + case 'stream_window_failed': + return 'streamProblemWindowFailed'; + case 'stream_crop_failed': + return problem.params.operation === 'load' + ? 'streamProblemCropLoadFailed' + : 'streamProblemCropSaveFailed'; + case 'stream_copy_failed': + return 'streamCopyFailed'; + case 'stream_open_failed': + return problem.params.operation === 'open_settings' + ? 'streamProblemOpenSettingsFailed' + : 'streamProblemOpenOverlayFailed'; + case 'stream_unexpected': + return 'streamProblemUnexpected'; + } +} diff --git a/src/features/stream/streamWorkflow.test.ts b/src/features/stream/streamWorkflow.test.ts index 005d5e9..49f0afb 100644 --- a/src/features/stream/streamWorkflow.test.ts +++ b/src/features/stream/streamWorkflow.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; -import { - createStreamWorkflow, - type StreamCommandPort, - type StreamScheduler -} from './streamWorkflow'; import { defaultCropSettings, idleStreamStatus } from '../../api/previewDefaults'; +import { commandClient } from '../../api/commandClient'; import type { StreamOverlayCropSettingsPayload, StreamServiceStatus } from '../../types/backend'; -import { commandClient } from '../../api/commandClient'; import { createStreamCommandPort } from './streamApi'; +import { + createStreamWorkflow, + type StreamCommandPort, + type StreamScheduler +} from './streamWorkflow'; function runningStatus( overrides: Partial = {} @@ -92,35 +92,18 @@ function fakeCommands( }; } -const copy = { - statusError: 'Error', - statusStarting: 'Starting', - statusRunning: 'Running', - statusIdle: 'Idle', - startingDetail: 'Starting service', - idleDetail: 'Service idle', - portDetail: (port: number) => `Port ${port}`, - dbConnected: 'Database connected', - dbMissing: 'Database missing', - windowLatest: 'Latest run', - windowOffset: (count: number) => `${count} earlier`, - copied: 'Copied', - copyFailed: 'Copy failed', - cropSaved: 'Crop saved', - cropReset: 'Crop reset' -}; - -function setup(commandOverrides: Partial = {}) { +function setup( + commandOverrides: Partial = {}, + clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }, + opener = { open: vi.fn().mockResolvedValue(undefined) } +) { const scheduler = new FakeScheduler(); const commands = fakeCommands(commandOverrides); - const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; - const opener = { open: vi.fn().mockResolvedValue(undefined) }; const workflow = createStreamWorkflow({ commands, scheduler, clipboard, - opener, - copy + opener }); return { workflow, commands, scheduler, clipboard, opener }; } @@ -130,261 +113,137 @@ async function flush() { await Promise.resolve(); } -describe('stream workflow', () => { - it('loads status and crop settings in parallel into one derived snapshot', async () => { - const { workflow, scheduler } = setup(); - - await workflow.start(); - - const snapshot = workflow.getSnapshot(); - expect(snapshot.phase).toBe('running'); - expect(snapshot.statusLabel).toBe('Running'); - expect(snapshot.statusDetail).toBe('Port 17654'); - expect(snapshot.dbLabel).toBe('Database connected'); - expect(snapshot.windowLabel).toBe('Latest run'); - expect(snapshot.cropSettings).toBe(defaultCropSettings); - expect(snapshot.canOpenOverlay).toBe(true); - expect(scheduler.intervals.size).toBe(1); - }); - - it('preserves successful initial data and applies stable error priority', async () => { - const crop: StreamOverlayCropSettingsPayload = { - ...defaultCropSettings, - code: 'crop-ok' - }; - const partial = setup({ - ensureSession: vi.fn().mockRejectedValue(new Error('status failed')), - loadCropSettings: vi.fn().mockResolvedValue(crop) - }); - - await partial.workflow.start(); - - expect(partial.workflow.getSnapshot().cropCode).toBe('crop-ok'); - expect(partial.workflow.getSnapshot().error).toBe('status failed'); - - const allFailed = setup({ - ensureSession: vi.fn().mockRejectedValue(new Error('status first')), - loadCropSettings: vi.fn().mockRejectedValue(new Error('crop second')) - }); - await allFailed.workflow.start(); - expect(allFailed.workflow.getSnapshot().error).toBe('status first'); - }); - - it('surfaces polling errors only at the threshold and stops after dispose', async () => { - const getStatus = vi.fn().mockRejectedValue(new Error('poll failed')); +describe('stream workflow lifecycle and effects', () => { + it('ignores an older poll after a newer poll succeeds', async () => { + const first = deferred(); + const second = deferred(); + const getStatus = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); const { workflow, scheduler } = setup({ getStatus }); await workflow.start(); scheduler.fireIntervals(); - await flush(); - scheduler.fireIntervals(); - await flush(); - expect(workflow.getSnapshot().error).toBeNull(); - scheduler.fireIntervals(); + second.resolve(runningStatus({ active_window_offset: 2 })); await flush(); - expect(workflow.getSnapshot().error).toBe('poll failed'); - - getStatus.mockResolvedValueOnce(runningStatus()); - scheduler.fireIntervals(); + first.resolve(runningStatus({ active_window_offset: 1 })); await flush(); - expect(workflow.getSnapshot().error).toBeNull(); - workflow.dispose(); - expect(scheduler.intervals.size).toBe(0); - scheduler.fireIntervals(); - await flush(); - expect(getStatus).toHaveBeenCalledTimes(4); + expect(workflow.getSnapshot().service.status?.active_window_offset).toBe(2); }); - it('counts overlapping slow failures toward the polling threshold', async () => { - const polls = [ - deferred(), - deferred(), - deferred() - ]; + it('does not mark a newer status stale when an older poll fails', async () => { + const first = deferred(); + const second = deferred(); const getStatus = vi .fn() - .mockImplementationOnce(() => polls[0].promise) - .mockImplementationOnce(() => polls[1].promise) - .mockImplementationOnce(() => polls[2].promise); + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); const { workflow, scheduler } = setup({ getStatus }); await workflow.start(); scheduler.fireIntervals(); scheduler.fireIntervals(); - scheduler.fireIntervals(); - expect(getStatus).toHaveBeenCalledTimes(3); - polls[0].reject(new Error('slow poll 1')); - await flush(); - polls[1].reject(new Error('slow poll 2')); + second.resolve(runningStatus({ active_window_offset: 2 })); await flush(); - expect(workflow.getSnapshot().error).toBeNull(); - polls[2].reject(new Error('slow poll 3')); + first.reject(new Error('outdated failure')); await flush(); - expect(workflow.getSnapshot().error).toBe('slow poll 3'); - }); - - it('refreshes after restart and preserves the usable status on failure', async () => { - const refreshed = runningStatus({ active_window_offset: 2 }); - const success = setup({ - restartSession: vi.fn().mockResolvedValue(runningStatus()), - getStatus: vi.fn().mockResolvedValue(refreshed) + expect(workflow.getSnapshot().polling).toMatchObject({ + phase: 'available', + freshness: 'fresh', + problem: null }); - await success.workflow.start(); - - expect(await success.workflow.intents.restart()).toBe(true); - expect(success.workflow.getSnapshot().status).toBe(refreshed); - - const failure = setup({ - restartSession: vi.fn().mockRejectedValue(new Error('restart failed')) - }); - await failure.workflow.start(); - const before = failure.workflow.getSnapshot().status; - - expect(await failure.workflow.intents.restart()).toBe(false); - expect(failure.workflow.getSnapshot().status).toBe(before); - expect(failure.workflow.getSnapshot().error).toBe('restart failed'); }); - it('does not let a slow poll overwrite the restart epoch', async () => { + it('does not let a slow poll overwrite a completed restart', async () => { const slowPoll = deferred(); const refreshed = runningStatus({ active_window_offset: 3 }); - let statusCalls = 0; - const getStatus = vi.fn(() => { - statusCalls += 1; - return statusCalls === 1 ? slowPoll.promise : Promise.resolve(refreshed); + const { workflow, scheduler } = setup({ + getStatus: vi.fn(() => slowPoll.promise), + restartSession: vi.fn().mockResolvedValue(refreshed) }); - const { workflow, scheduler } = setup({ getStatus }); await workflow.start(); scheduler.fireIntervals(); await flush(); - await workflow.intents.restart(); - expect(workflow.getSnapshot().status).toBe(refreshed); - + expect(await workflow.intents.restart()).toBe(true); slowPoll.resolve(runningStatus({ active_window_offset: 1 })); await flush(); - expect(workflow.getSnapshot().status).toBe(refreshed); - }); - it('does not let an older poll overwrite a newer poll', async () => { - const first = deferred(); - const second = deferred(); - const getStatus = vi - .fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise); - const { workflow, scheduler } = setup({ getStatus }); - await workflow.start(); - - scheduler.fireIntervals(); - scheduler.fireIntervals(); - second.resolve(runningStatus({ active_window_offset: 2 })); - await flush(); - first.resolve(runningStatus({ active_window_offset: 1 })); - await flush(); - - expect(workflow.getSnapshot().status.active_window_offset).toBe(2); - }); - - it('clamps window offsets and rejects conflicting actions while busy', async () => { - const pendingWindow = deferred(); - const setWindow = vi.fn(() => pendingWindow.promise); - const { workflow } = setup({ setWindow }); - await workflow.start(); - - const first = workflow.intents.moveWindow(-100); - expect(await workflow.intents.moveWindow(1)).toBe(false); - expect(workflow.getSnapshot().action).toBe('window'); - expect(workflow.getSnapshot().canRestart).toBe(false); - expect(workflow.getSnapshot().canEditCrop).toBe(false); - expect(setWindow).toHaveBeenCalledTimes(1); - expect(setWindow).toHaveBeenCalledWith(0); - - pendingWindow.resolve(runningStatus({ active_window_offset: 0 })); - expect(await first).toBe(true); - }); - - it('normalizes crop input and applies returned settings', async () => { - const saved = { ...defaultCropSettings, code: 'normalized' }; - const applyCropCode = vi.fn().mockResolvedValue(saved); - const { workflow } = setup({ applyCropCode }); - await workflow.start(); - - workflow.intents.setCropCode(' input '); - expect(await workflow.intents.submitCropCode()).toBe(true); - - expect(applyCropCode).toHaveBeenCalledWith('input'); - expect(workflow.getSnapshot().cropSettings).toBe(saved); - expect(workflow.getSnapshot().cropCode).toBe('normalized'); - expect(workflow.getSnapshot().feedback?.text).toBe('Crop saved'); + expect(workflow.getSnapshot().service.status).toBe(refreshed); }); - it('applies display-mode and reset responses through the same crop state', async () => { - const modeSettings = { - ...defaultCropSettings, - display_mode: 'hero' as const + it('maps an authoritative runtime error separately from polling staleness', async () => { + const failedStatus = { + ...idleStreamStatus, + last_error: 'port occupied' }; - const resetSettings = { ...defaultCropSettings, code: 'reset-code' }; - const saveDisplayMode = vi.fn().mockResolvedValue(modeSettings); - const resetCropSettings = vi.fn().mockResolvedValue(resetSettings); - const { workflow } = setup({ saveDisplayMode, resetCropSettings }); + const { workflow } = setup({ + ensureSession: vi.fn().mockResolvedValue(failedStatus) + }); await workflow.start(); - expect(await workflow.intents.changeDisplayMode('hero')).toBe(true); - expect(workflow.getSnapshot().cropSettings).toBe(modeSettings); - expect(await workflow.intents.resetCropCode()).toBe(true); - expect(workflow.getSnapshot().cropSettings).toBe(resetSettings); - expect(workflow.getSnapshot().cropCode).toBe('reset-code'); - expect(workflow.getSnapshot().feedback?.text).toBe('Crop reset'); + expect(workflow.getSnapshot().service).toMatchObject({ + phase: 'degraded', + status: failedStatus, + problem: { + code: 'stream_service_failed', + diagnostic: 'port occupied' + } + }); + expect(workflow.getSnapshot().polling.freshness).toBe('fresh'); }); - it('handles copy/open outcomes and clears transient messages on schedule', async () => { - const { workflow, scheduler, clipboard, opener } = setup(); + it('keeps semantic notices transient and one-off failures target-scoped', async () => { + const clipboard = { + writeText: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('clipboard denied')) + }; + const opener = { + open: vi.fn().mockRejectedValueOnce(new Error('open denied')) + }; + const { workflow, scheduler } = setup({}, clipboard, opener); await workflow.start(); expect(await workflow.intents.copyObsUrl()).toBe(true); - expect(clipboard.writeText).toHaveBeenCalledWith( - 'http://127.0.0.1:17654/overlay' - ); - expect(workflow.getSnapshot().feedback).toEqual({ - text: 'Copied', - tone: 'success' - }); + expect(workflow.getSnapshot().notice?.code).toBe('stream_obs_url_copied'); scheduler.fireTimeouts(); - expect(workflow.getSnapshot().feedback).toBeNull(); + expect(workflow.getSnapshot().notice).toBeNull(); - clipboard.writeText.mockRejectedValueOnce(new Error('denied')); - expect(await workflow.intents.copyObsUrl()).toBe(true); - expect(workflow.getSnapshot().feedback).toEqual({ - text: 'Copy failed', - tone: 'error' + expect(await workflow.intents.copyObsUrl()).toBe(false); + expect(workflow.getSnapshot().oneOff.problems.copy).toMatchObject({ + code: 'stream_copy_failed', + diagnostic: 'clipboard denied' }); - - expect(await workflow.intents.openOverlay()).toBe(true); - expect(opener.open).toHaveBeenCalledWith('http://127.0.0.1:17654/overlay'); - opener.open.mockRejectedValueOnce(new Error('open failed')); - expect(await workflow.intents.openSettings()).toBe(false); - expect(workflow.getSnapshot().error).toBe('open failed'); + expect(await workflow.intents.openOverlay()).toBe(false); + expect(workflow.getSnapshot().oneOff.problems.open_overlay).toMatchObject({ + code: 'stream_open_failed', + diagnostic: 'open denied' + }); + expect(workflow.getSnapshot().crop.canEdit).toBe(true); }); - it('ignores slow responses after disposal', async () => { + it('ignores responses and cancels timers after disposal', async () => { const slow = deferred(); const { workflow, scheduler } = setup({ getStatus: () => slow.promise }); await workflow.start(); - const before = workflow.getSnapshot(); scheduler.fireIntervals(); + const before = workflow.getSnapshot(); workflow.dispose(); slow.resolve(runningStatus({ active_window_offset: 9 })); await flush(); expect(workflow.getSnapshot()).toBe(before); + expect(scheduler.intervals.size).toBe(0); }); - it('supports a dispose-start lifecycle replay without accepting the old initialization', async () => { + it('supports a dispose-start replay without accepting old initialization', async () => { const firstStatus = deferred(); const firstCrop = deferred(); const secondStatus = deferred(); @@ -405,22 +264,20 @@ describe('stream workflow', () => { const firstStart = workflow.start(); workflow.dispose(); const secondStart = workflow.start(); - firstStatus.resolve(runningStatus({ active_window_offset: 1 })); firstCrop.resolve({ ...defaultCropSettings, code: 'stale' }); await firstStart; - expect(workflow.getSnapshot().phase).toBe('starting'); secondStatus.resolve(runningStatus({ active_window_offset: 2 })); secondCrop.resolve({ ...defaultCropSettings, code: 'current' }); await secondStart; - expect(workflow.getSnapshot().status.active_window_offset).toBe(2); - expect(workflow.getSnapshot().cropCode).toBe('current'); + expect(workflow.getSnapshot().service.status?.active_window_offset).toBe(2); + expect(workflow.getSnapshot().crop.code).toBe('current'); expect(scheduler.intervals.size).toBe(1); }); - it('runs unchanged with generated/native-shaped and Preview semantic adapters', async () => { + it('runs through generated/native-shaped and Preview command adapters', async () => { const nativeLike = { ensureStreamSession: vi.fn().mockResolvedValue(runningStatus()), getStreamStatus: vi.fn().mockResolvedValue(runningStatus()), @@ -440,11 +297,10 @@ describe('stream workflow', () => { commands, scheduler: new FakeScheduler(), clipboard: { writeText: async () => undefined }, - opener: { open: async () => undefined }, - copy + opener: { open: async () => undefined } }); await workflow.start(); - expect(workflow.getSnapshot().phase).not.toBe('starting'); + expect(workflow.getSnapshot().service.phase).not.toBe('loading'); workflow.dispose(); } }); diff --git a/src/features/stream/streamWorkflow.ts b/src/features/stream/streamWorkflow.ts index e11e980..8a32a6c 100644 --- a/src/features/stream/streamWorkflow.ts +++ b/src/features/stream/streamWorkflow.ts @@ -3,23 +3,24 @@ import type { StreamOverlayDisplayMode, StreamServiceStatus } from '../../types/backend'; +import { defaultCropSettings } from '../../api/previewDefaults'; import { - defaultCropSettings, - idleStreamStatus -} from '../../api/previewDefaults'; -import { toErrorMessage } from '../shared/errors'; - -export type StreamAction = - | 'restart' - | 'copy' - | 'open_overlay' - | 'open_settings' - | 'crop' - | 'display_mode' - | 'window'; - -export type StreamPagePhase = 'error' | 'starting' | 'running' | 'idle'; -export type StreamFeedbackTone = 'success' | 'error'; + streamProblemFromError, + streamRuntimeProblem, + type StreamNotice, + type StreamProblem, + type StreamProblemCode +} from './streamProblems'; + +export type StreamCapabilityPhase = + | 'loading' + | 'available' + | 'degraded' + | 'unavailable'; + +type StatusOperation = 'restart' | 'window'; +type CropOperation = 'load' | 'crop' | 'display_mode' | 'reset'; +type OneOffAction = 'copy' | 'open_overlay' | 'open_settings'; export interface StreamCommandPort { ensureSession(): Promise; @@ -49,50 +50,51 @@ export interface StreamOpener { open(url: string): Promise; } -export interface StreamWorkflowCopy { - statusError: string; - statusStarting: string; - statusRunning: string; - statusIdle: string; - startingDetail: string; - idleDetail: string; - portDetail(port: number): string; - dbConnected: string; - dbMissing: string; - windowLatest: string; - windowOffset(count: number): string; - copied: string; - copyFailed: string; - cropSaved: string; - cropReset: string; -} - export interface StreamPageSnapshot { - status: StreamServiceStatus; - cropSettings: StreamOverlayCropSettingsPayload; - cropCode: string; - phase: StreamPagePhase; - statusLabel: string; - statusDetail: string; - dbLabel: string; - windowLabel: string; - action: StreamAction | null; - error: string | null; - feedback: { text: string; tone: StreamFeedbackTone } | null; - obsUrl: string | null; - settingsUrl: string | null; - isBusy: boolean; - canOpenOverlay: boolean; - canCopyObsUrl: boolean; - canOpenSettings: boolean; - canRestart: boolean; - canMoveMoreHistory: boolean; - canMoveLessHistory: boolean; - canEditCrop: boolean; + service: { + phase: Extract; + status: StreamServiceStatus | null; + problem: StreamProblem | null; + operation: Extract | null; + canRestart: boolean; + }; + polling: { + phase: Extract; + freshness: 'unknown' | 'fresh' | 'stale'; + problem: StreamProblem | null; + operation: 'poll' | 'retry' | null; + }; + window: { + phase: StreamCapabilityPhase; + problem: StreamProblem | null; + operation: Extract | null; + canMoveMoreHistory: boolean; + canMoveLessHistory: boolean; + }; + crop: { + phase: Extract; + settings: StreamOverlayCropSettingsPayload; + code: string; + problem: StreamProblem | null; + operation: CropOperation | null; + canEdit: boolean; + }; + oneOff: { + operations: Record; + problems: Record; + obsUrl: string | null; + settingsUrl: string | null; + canOpenOverlay: boolean; + canCopyObsUrl: boolean; + canOpenSettings: boolean; + }; + notice: StreamNotice | null; } export interface StreamWorkflowIntents { restart(): Promise; + retryStatus(): Promise; + reloadCropSettings(): Promise; copyObsUrl(): Promise; openOverlay(): Promise; openSettings(): Promise; @@ -116,20 +118,26 @@ interface StreamWorkflowPorts { scheduler: StreamScheduler; clipboard: StreamClipboard; opener: StreamOpener; - copy: StreamWorkflowCopy; } interface MutableState { - status: StreamServiceStatus; + status: StreamServiceStatus | null; + serviceLoading: boolean; + serviceProblem: StreamProblem | null; + pollingFreshness: 'unknown' | 'fresh' | 'stale'; + pollingProblem: StreamProblem | null; + pollingRequests: number; + manualPollingRequests: number; cropSettings: StreamOverlayCropSettingsPayload; cropCode: string; - loading: boolean; - action: StreamAction | null; - actionError: string | null; - statusLoadError: string | null; - cropLoadError: string | null; - pollError: string | null; - transient: { text: string; tone: StreamFeedbackTone } | null; + cropLoading: boolean; + cropProblem: StreamProblem | null; + statusOperation: StatusOperation | null; + windowProblem: StreamProblem | null; + cropOperation: CropOperation | null; + oneOffOperations: Set; + oneOffProblems: Record; + notice: StreamNotice | null; } const POLL_INTERVAL_MS = 2_000; @@ -138,31 +146,21 @@ const TRANSIENT_MESSAGE_MS = 3_000; class DefaultStreamWorkflow implements StreamWorkflow { private readonly listeners = new Set<() => void>(); - private state: MutableState = { - status: idleStreamStatus, - cropSettings: defaultCropSettings, - cropCode: defaultCropSettings.code, - loading: true, - action: null, - actionError: null, - statusLoadError: null, - cropLoadError: null, - pollError: null, - transient: null - }; + private state: MutableState = initialState(); private snapshot: StreamPageSnapshot; private started = false; private disposed = false; private lifecycleEpoch = 0; private statusEpoch = 0; private latestPollRequest = 0; - private latestSuccessfulPollRequest = 0; private consecutivePollFailures = 0; private intervalHandle: unknown = null; - private messageTimeoutHandle: unknown = null; + private noticeTimeoutHandle: unknown = null; readonly intents: StreamWorkflowIntents = { restart: () => this.restart(), + retryStatus: () => this.poll(true), + reloadCropSettings: () => this.reloadCropSettings(), copyObsUrl: () => this.copyObsUrl(), openOverlay: () => this.openOverlay(), openSettings: () => this.openSettings(), @@ -189,40 +187,19 @@ class DefaultStreamWorkflow implements StreamWorkflow { this.started = true; this.disposed = false; const lifecycle = ++this.lifecycleEpoch; - this.state.loading = true; - this.state.action = null; - this.state.actionError = null; - this.state.statusLoadError = null; - this.state.cropLoadError = null; - this.state.pollError = null; + const statusEpoch = ++this.statusEpoch; + this.state = initialState(); this.consecutivePollFailures = 0; - this.clearTransient(); + this.clearNoticeTimer(); this.publish(); - const epoch = ++this.statusEpoch; - const [statusResult, cropResult] = await Promise.allSettled([ - this.ports.commands.ensureSession(), - this.ports.commands.loadCropSettings() - ]); - if (!this.isCurrentLifecycle(lifecycle)) return; - if (statusResult.status === 'fulfilled' && epoch === this.statusEpoch) { - this.state.status = statusResult.value; - this.state.statusLoadError = null; - } else if (statusResult.status === 'rejected') { - this.state.statusLoadError = toErrorMessage(statusResult.reason); - } - - if (cropResult.status === 'fulfilled') { - this.applyCropSettings(cropResult.value); - this.state.cropLoadError = null; - } else { - this.state.cropLoadError = toErrorMessage(cropResult.reason); - } + const statusLoad = this.loadInitialStatus(lifecycle, statusEpoch); + const cropLoad = this.loadInitialCrop(lifecycle); + await Promise.all([statusLoad, cropLoad]); + if (!this.isCurrentLifecycle(lifecycle)) return; - this.state.loading = false; - this.publish(); this.intervalHandle = this.ports.scheduler.setInterval( - () => void this.poll(), + () => void this.poll(false), POLL_INTERVAL_MS ); } @@ -234,107 +211,232 @@ class DefaultStreamWorkflow implements StreamWorkflow { this.lifecycleEpoch += 1; this.statusEpoch += 1; this.latestPollRequest += 1; - this.state.action = null; + this.state.statusOperation = null; + this.state.cropOperation = null; + this.state.oneOffOperations.clear(); if (this.intervalHandle !== null) { this.ports.scheduler.clearInterval(this.intervalHandle); this.intervalHandle = null; } - this.clearMessageTimer(); + this.clearNoticeTimer(); this.listeners.clear(); } - private async poll() { - if (this.disposed || this.state.action !== null) return; + private async loadInitialStatus(lifecycle: number, epoch: number) { + try { + const status = await this.ports.commands.ensureSession(); + if (!this.isCurrentLifecycle(lifecycle) || epoch !== this.statusEpoch) { + return; + } + this.applyStatus(status); + } catch (caught) { + if (!this.isCurrentLifecycle(lifecycle) || epoch !== this.statusEpoch) { + return; + } + this.state.serviceProblem = streamProblemFromError( + caught, + 'stream_service_failed', + { operation: 'ensure' } + ); + this.state.pollingFreshness = 'unknown'; + } finally { + if (this.isCurrentLifecycle(lifecycle) && epoch === this.statusEpoch) { + this.state.serviceLoading = false; + this.publish(); + } + } + } + + private async loadInitialCrop(lifecycle: number) { + try { + const settings = await this.ports.commands.loadCropSettings(); + if (!this.isCurrentLifecycle(lifecycle)) return; + this.applyCropSettings(settings); + this.state.cropProblem = null; + } catch (caught) { + if (!this.isCurrentLifecycle(lifecycle)) return; + this.state.cropProblem = streamProblemFromError( + caught, + 'stream_crop_failed', + { operation: 'load' } + ); + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.cropLoading = false; + this.publish(); + } + } + } + + private async poll(manual: boolean): Promise { + if (this.disposed || this.state.statusOperation !== null) return false; const lifecycle = this.lifecycleEpoch; const epoch = this.statusEpoch; const request = ++this.latestPollRequest; + this.state.pollingRequests += 1; + if (manual) this.state.manualPollingRequests += 1; + this.publish(); + try { const status = await this.ports.commands.getStatus(); if ( !this.isCurrentLifecycle(lifecycle) || epoch !== this.statusEpoch || request !== this.latestPollRequest - ) - return; - this.state.status = status; - this.state.statusLoadError = null; - this.state.pollError = null; - this.latestSuccessfulPollRequest = request; - this.consecutivePollFailures = 0; - this.publish(); + ) { + return false; + } + this.applyStatus(status); + return true; } catch (caught) { if ( !this.isCurrentLifecycle(lifecycle) || epoch !== this.statusEpoch || - request < this.latestSuccessfulPollRequest - ) - return; + request !== this.latestPollRequest + ) { + return false; + } this.consecutivePollFailures += 1; if (this.consecutivePollFailures >= POLL_FAILURE_THRESHOLD) { - this.state.pollError = toErrorMessage(caught); + this.state.pollingFreshness = 'stale'; + this.state.pollingProblem = streamProblemFromError( + caught, + 'stream_poll_failed', + { operation: 'poll_status' } + ); + } + return false; + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.pollingRequests = Math.max( + 0, + this.state.pollingRequests - 1 + ); + if (manual) { + this.state.manualPollingRequests = Math.max( + 0, + this.state.manualPollingRequests - 1 + ); + } this.publish(); } } } - private restart() { - return this.runAction( - 'restart', - async (lifecycle) => { - await this.ports.commands.restartSession(); - const status = await this.ports.commands.getStatus(); - if (!this.isCurrentLifecycle(lifecycle)) return; - this.state.status = status; - this.state.statusLoadError = null; - this.state.pollError = null; - this.consecutivePollFailures = 0; - }, - { invalidateStatus: true } - ); + private async restart(): Promise { + if ( + this.disposed || + this.state.serviceLoading || + this.state.statusOperation !== null + ) { + return false; + } + const lifecycle = this.lifecycleEpoch; + this.state.statusOperation = 'restart'; + this.state.serviceProblem = null; + this.invalidateStatusRequests(); + this.publish(); + + try { + const status = await this.ports.commands.restartSession(); + if (!this.isCurrentLifecycle(lifecycle)) return false; + this.applyStatus(status); + return true; + } catch (caught) { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.serviceProblem = streamProblemFromError( + caught, + 'stream_service_failed', + { operation: 'restart' } + ); + this.state.pollingFreshness = 'stale'; + } + return false; + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.statusOperation = null; + this.publish(); + } + } + } + + private async reloadCropSettings(): Promise { + if ( + this.disposed || + this.state.cropLoading || + this.state.cropOperation !== null + ) { + return false; + } + const lifecycle = this.lifecycleEpoch; + this.state.cropLoading = true; + this.state.cropOperation = 'load'; + this.state.cropProblem = null; + this.publish(); + try { + const settings = await this.ports.commands.loadCropSettings(); + if (!this.isCurrentLifecycle(lifecycle)) return false; + this.applyCropSettings(settings); + return true; + } catch (caught) { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.cropProblem = streamProblemFromError( + caught, + 'stream_crop_failed', + { operation: 'load' } + ); + } + return false; + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.cropLoading = false; + this.state.cropOperation = null; + this.publish(); + } + } } private copyObsUrl() { - return this.runAction( + const url = this.state.status?.overlay_url; + if (!url) return Promise.resolve(false); + return this.runOneOff( 'copy', - async (lifecycle) => { - const url = this.state.status.overlay_url; - if (!url) return; - try { - await this.ports.clipboard.writeText(url); - if (this.isCurrentLifecycle(lifecycle)) { - this.showTransient(this.ports.copy.copied, 'success'); - } - } catch { - if (this.isCurrentLifecycle(lifecycle)) { - this.showTransient(this.ports.copy.copyFailed, 'error'); - } - } - }, - { clearTransient: true } + () => this.ports.clipboard.writeText(url), + 'stream_copy_failed', + { operation: 'copy_obs_url' }, + { code: 'stream_obs_url_copied', params: {} } ); } private openOverlay() { - return this.runAction('open_overlay', async () => { - const url = this.state.status.overlay_url; - if (url) await this.ports.opener.open(url); - }); + const url = this.state.status?.overlay_url; + if (!url) return Promise.resolve(false); + return this.runOneOff( + 'open_overlay', + () => this.ports.opener.open(url), + 'stream_open_failed', + { operation: 'open_overlay' } + ); } private openSettings() { - return this.runAction('open_settings', async () => { - const url = this.state.status.settings_url; - if (url) await this.ports.opener.open(url); - }); + const url = this.state.status?.settings_url; + if (!url) return Promise.resolve(false); + return this.runOneOff( + 'open_settings', + () => this.ports.opener.open(url), + 'stream_open_failed', + { operation: 'open_settings' } + ); } private changeDisplayMode(displayMode: StreamOverlayDisplayMode) { - return this.runAction('display_mode', async (lifecycle) => { - const settings = await this.ports.commands.saveDisplayMode(displayMode); - if (!this.isCurrentLifecycle(lifecycle)) return; - this.applyCropSettings(settings, false); - this.state.cropLoadError = null; - }); + return this.runCropAction( + 'display_mode', + () => this.ports.commands.saveDisplayMode(displayMode), + { operation: 'save_display_mode' }, + false + ); } private setCropCode(value: string) { @@ -344,125 +446,191 @@ class DefaultStreamWorkflow implements StreamWorkflow { } private submitCropCode() { - return this.runAction( + return this.runCropAction( 'crop', - async (lifecycle) => { - const settings = await this.ports.commands.applyCropCode( - this.state.cropCode.trim() - ); - if (!this.isCurrentLifecycle(lifecycle)) return; - this.applyCropSettings(settings); - this.state.cropLoadError = null; - this.showTransient(this.ports.copy.cropSaved, 'success'); - }, - { clearTransient: true } + () => this.ports.commands.applyCropCode(this.state.cropCode.trim()), + { operation: 'apply_code' }, + true, + { code: 'stream_crop_saved', params: {} } ); } private resetCropCode() { - return this.runAction( - 'crop', - async (lifecycle) => { - const settings = await this.ports.commands.resetCropSettings(); - if (!this.isCurrentLifecycle(lifecycle)) return; - this.applyCropSettings(settings); - this.state.cropLoadError = null; - this.showTransient(this.ports.copy.cropReset, 'success'); - }, - { clearTransient: true } + return this.runCropAction( + 'reset', + () => this.ports.commands.resetCropSettings(), + { operation: 'reset' }, + true, + { code: 'stream_crop_reset', params: {} } ); } - private moveWindow(delta: number) { - return this.runAction( - 'window', - async (lifecycle) => { - const offset = Math.max( - 0, - Math.trunc(this.state.status.active_window_offset + delta) - ); - const status = await this.ports.commands.setWindow(offset); - if (!this.isCurrentLifecycle(lifecycle)) return; - this.state.status = status; - this.state.statusLoadError = null; - this.state.pollError = null; - }, - { invalidateStatus: true } + private async moveWindow(delta: number): Promise { + if ( + this.disposed || + this.state.statusOperation !== null || + !this.state.status?.running || + this.state.pollingFreshness !== 'fresh' + ) { + return false; + } + const lifecycle = this.lifecycleEpoch; + const offset = Math.max( + 0, + Math.trunc(this.state.status.active_window_offset + delta) ); + this.state.statusOperation = 'window'; + this.state.windowProblem = null; + this.invalidateStatusRequests(); + this.publish(); + + try { + const status = await this.ports.commands.setWindow(offset); + if (!this.isCurrentLifecycle(lifecycle)) return false; + this.applyStatus(status); + return true; + } catch (caught) { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.windowProblem = streamProblemFromError( + caught, + 'stream_window_failed', + { operation: 'set_window', offset: String(offset) } + ); + } + return false; + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.statusOperation = null; + this.publish(); + } + } } - private async runAction( - action: StreamAction, - task: (lifecycle: number) => Promise, - options: { clearTransient?: boolean; invalidateStatus?: boolean } = {} - ) { - if (this.disposed || this.state.action !== null) return false; + private async runCropAction( + operation: Exclude, + task: () => Promise, + params: Record, + updateCode: boolean, + notice: StreamNotice | null = null + ): Promise { + if ( + this.disposed || + this.state.cropLoading || + this.state.cropOperation !== null + ) { + return false; + } const lifecycle = this.lifecycleEpoch; - this.state.action = action; - this.state.actionError = null; - if (options.invalidateStatus) { - this.statusEpoch += 1; - this.latestPollRequest += 1; + this.state.cropOperation = operation; + this.state.cropProblem = null; + if (notice) this.clearNotice(); + this.publish(); + + try { + const settings = await task(); + if (!this.isCurrentLifecycle(lifecycle)) return false; + this.applyCropSettings(settings, updateCode); + if (notice) this.showNotice(notice); + return true; + } catch (caught) { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.cropProblem = streamProblemFromError( + caught, + 'stream_crop_failed', + params + ); + } + return false; + } finally { + if (this.isCurrentLifecycle(lifecycle)) { + this.state.cropOperation = null; + this.publish(); + } } - if (options.clearTransient) this.clearTransient(); + } + + private async runOneOff( + action: OneOffAction, + task: () => Promise, + fallbackCode: StreamProblemCode, + params: Record, + notice: StreamNotice | null = null + ): Promise { + if (this.disposed || this.state.oneOffOperations.has(action)) return false; + const lifecycle = this.lifecycleEpoch; + this.state.oneOffOperations.add(action); + this.state.oneOffProblems[action] = null; + if (notice) this.clearNotice(); this.publish(); try { - await task(lifecycle); + await task(); + if (!this.isCurrentLifecycle(lifecycle)) return false; + if (notice) this.showNotice(notice); return true; } catch (caught) { if (this.isCurrentLifecycle(lifecycle)) { - this.state.actionError = toErrorMessage(caught); + this.state.oneOffProblems[action] = streamProblemFromError( + caught, + fallbackCode, + params + ); } return false; } finally { if (this.isCurrentLifecycle(lifecycle)) { - this.state.action = null; + this.state.oneOffOperations.delete(action); this.publish(); } } } + private applyStatus(status: StreamServiceStatus) { + this.state.status = status; + this.state.serviceProblem = status.last_error + ? streamRuntimeProblem(status.last_error) + : null; + this.state.pollingFreshness = 'fresh'; + this.state.pollingProblem = null; + this.consecutivePollFailures = 0; + } + private applyCropSettings( settings: StreamOverlayCropSettingsPayload, updateCode = true ) { this.state.cropSettings = settings; if (updateCode) this.state.cropCode = settings.code; + this.state.cropProblem = null; + } + + private invalidateStatusRequests() { + this.statusEpoch += 1; + this.latestPollRequest += 1; } - private showTransient(text: string, tone: StreamFeedbackTone) { + private showNotice(notice: StreamNotice) { const lifecycle = this.lifecycleEpoch; - this.clearMessageTimer(); - this.state.transient = { text, tone }; - this.messageTimeoutHandle = this.ports.scheduler.setTimeout(() => { - this.messageTimeoutHandle = null; + this.clearNoticeTimer(); + this.state.notice = notice; + this.noticeTimeoutHandle = this.ports.scheduler.setTimeout(() => { + this.noticeTimeoutHandle = null; if (!this.isCurrentLifecycle(lifecycle)) return; - this.state.transient = null; + this.state.notice = null; this.publish(); }, TRANSIENT_MESSAGE_MS); this.publish(); } - private clearTransient() { - this.clearMessageTimer(); - this.state.transient = null; + private clearNotice() { + this.clearNoticeTimer(); + this.state.notice = null; } - private clearMessageTimer() { - if (this.messageTimeoutHandle === null) return; - this.ports.scheduler.clearTimeout(this.messageTimeoutHandle); - this.messageTimeoutHandle = null; - } - - private currentError() { - return ( - this.state.actionError ?? - this.state.statusLoadError ?? - this.state.cropLoadError ?? - this.state.pollError ?? - this.state.status.last_error - ); + private clearNoticeTimer() { + if (this.noticeTimeoutHandle === null) return; + this.ports.scheduler.clearTimeout(this.noticeTimeoutHandle); + this.noticeTimeoutHandle = null; } private isCurrentLifecycle(lifecycle: number) { @@ -470,65 +638,91 @@ class DefaultStreamWorkflow implements StreamWorkflow { } private deriveSnapshot(): StreamPageSnapshot { - const error = this.currentError(); - const isBusy = this.state.loading || this.state.action !== null; - const phase: StreamPagePhase = error - ? 'error' - : this.state.loading - ? 'starting' - : this.state.status.running - ? 'running' - : 'idle'; - const statusLabel = - phase === 'error' - ? this.ports.copy.statusError - : phase === 'starting' - ? this.ports.copy.statusStarting - : phase === 'running' - ? this.ports.copy.statusRunning - : this.ports.copy.statusIdle; - const statusDetail = error - ? error - : phase === 'starting' - ? this.ports.copy.startingDetail - : phase === 'running' && this.state.status.port !== null - ? this.ports.copy.portDetail(this.state.status.port) - : this.ports.copy.idleDetail; - const controlsAvailable = !isBusy && error === null; - const running = this.state.status.running; + const status = this.state.status; + const servicePhase = this.state.serviceLoading + ? 'loading' + : this.state.serviceProblem + ? 'degraded' + : 'available'; + const pollingPhase = this.state.serviceLoading + ? 'loading' + : this.state.pollingFreshness !== 'fresh' + ? 'degraded' + : 'available'; + const authoritativeRunning = + status?.running === true && + this.state.pollingFreshness === 'fresh' && + this.state.serviceProblem === null; + const statusOperationBusy = this.state.statusOperation !== null; + const windowPhase: StreamCapabilityPhase = this.state.serviceLoading + ? 'loading' + : this.state.windowProblem + ? 'degraded' + : authoritativeRunning + ? 'available' + : 'unavailable'; + const cropPhase = this.state.cropLoading + ? 'loading' + : this.state.cropProblem + ? 'degraded' + : 'available'; + const copyBusy = this.state.oneOffOperations.has('copy'); + const overlayBusy = this.state.oneOffOperations.has('open_overlay'); + const settingsBusy = this.state.oneOffOperations.has('open_settings'); return { - status: this.state.status, - cropSettings: this.state.cropSettings, - cropCode: this.state.cropCode, - phase, - statusLabel, - statusDetail, - dbLabel: this.state.status.db.found - ? this.ports.copy.dbConnected - : this.ports.copy.dbMissing, - windowLabel: - this.state.status.active_window_offset === 0 - ? this.ports.copy.windowLatest - : this.ports.copy.windowOffset( - this.state.status.active_window_offset - ), - action: this.state.action, - error, - feedback: error ? { text: error, tone: 'error' } : this.state.transient, - obsUrl: this.state.status.overlay_url, - settingsUrl: this.state.status.settings_url, - isBusy, - canOpenOverlay: - controlsAvailable && running && this.state.status.overlay_url !== null, - canCopyObsUrl: !isBusy && this.state.status.overlay_url !== null, - canOpenSettings: - controlsAvailable && running && this.state.status.settings_url !== null, - canRestart: !isBusy, - canMoveMoreHistory: !isBusy && running, - canMoveLessHistory: - !isBusy && running && this.state.status.active_window_offset > 0, - canEditCrop: !isBusy + service: { + phase: servicePhase, + status, + problem: this.state.serviceProblem, + operation: this.state.statusOperation === 'restart' ? 'restart' : null, + canRestart: !this.state.serviceLoading && !statusOperationBusy + }, + polling: { + phase: pollingPhase, + freshness: this.state.pollingFreshness, + problem: this.state.pollingProblem, + operation: + this.state.manualPollingRequests > 0 + ? 'retry' + : this.state.pollingRequests > 0 + ? 'poll' + : null + }, + window: { + phase: windowPhase, + problem: this.state.windowProblem, + operation: this.state.statusOperation === 'window' ? 'window' : null, + canMoveMoreHistory: authoritativeRunning && !statusOperationBusy, + canMoveLessHistory: + authoritativeRunning && + !statusOperationBusy && + (status?.active_window_offset ?? 0) > 0 + }, + crop: { + phase: cropPhase, + settings: this.state.cropSettings, + code: this.state.cropCode, + problem: this.state.cropProblem, + operation: this.state.cropOperation, + canEdit: !this.state.cropLoading && this.state.cropOperation === null + }, + oneOff: { + operations: { + copy: copyBusy, + open_overlay: overlayBusy, + open_settings: settingsBusy + }, + problems: { ...this.state.oneOffProblems }, + obsUrl: status?.overlay_url ?? null, + settingsUrl: status?.settings_url ?? null, + canOpenOverlay: + authoritativeRunning && !statusOperationBusy && !overlayBusy, + canCopyObsUrl: status?.overlay_url != null && !copyBusy, + canOpenSettings: + authoritativeRunning && !statusOperationBusy && !settingsBusy + }, + notice: this.state.notice }; } @@ -539,6 +733,32 @@ class DefaultStreamWorkflow implements StreamWorkflow { } } +function initialState(): MutableState { + return { + status: null, + serviceLoading: true, + serviceProblem: null, + pollingFreshness: 'unknown', + pollingProblem: null, + pollingRequests: 0, + manualPollingRequests: 0, + cropSettings: defaultCropSettings, + cropCode: defaultCropSettings.code, + cropLoading: true, + cropProblem: null, + statusOperation: null, + windowProblem: null, + cropOperation: null, + oneOffOperations: new Set(), + oneOffProblems: { + copy: null, + open_overlay: null, + open_settings: null + }, + notice: null + }; +} + export function createStreamWorkflow( ports: StreamWorkflowPorts ): StreamWorkflow { diff --git a/src/features/stream/useStreamPage.ts b/src/features/stream/useStreamPage.ts index c15d523..2e83a90 100644 --- a/src/features/stream/useStreamPage.ts +++ b/src/features/stream/useStreamPage.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react'; -import { useI18n } from '../../i18n/LocaleProvider'; import { streamCommandPort, streamOpener } from './streamApi'; import { createStreamWorkflow, @@ -19,33 +18,15 @@ const browserClipboard: StreamClipboard = { }; export function useStreamPage() { - const { t } = useI18n(); const workflow = useMemo( () => createStreamWorkflow({ commands: streamCommandPort, scheduler: browserScheduler, clipboard: browserClipboard, - opener: streamOpener, - copy: { - statusError: t('streamStatusError'), - statusStarting: t('streamStatusStarting'), - statusRunning: t('streamStatusRunning'), - statusIdle: t('streamStatusIdle'), - startingDetail: t('streamStarting'), - idleDetail: t('streamIdleDetail'), - portDetail: (port) => t('streamPortDetail', { port }), - dbConnected: t('dbConnected'), - dbMissing: t('dbMissing'), - windowLatest: t('streamWindowLatest'), - windowOffset: (count) => t('streamWindowOffset', { count }), - copied: t('streamCopied'), - copyFailed: t('streamCopyFailed'), - cropSaved: t('streamCropSaved'), - cropReset: t('streamCropReset') - } + opener: streamOpener }), - [t] + [] ); const snapshot = useSyncExternalStore( workflow.subscribe, diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 8a783ba..2cdab32 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -295,13 +295,33 @@ const zh = { streamStatusStarting: '正在启动叠加层', streamStatusRunning: '叠加层运行中', streamStatusIdle: '叠加层空闲', + streamStatusStale: '叠加层状态可能已过期', + streamStatusUnavailable: '叠加层状态不可用', streamStarting: '正在启动本地服务', streamIdleDetail: '服务尚未启动', + streamStaleRunningDetail: '上次检测为运行中,正在等待最新状态', + streamStaleIdleDetail: '上次检测为未运行,正在等待最新状态', + streamStatusUnavailableDetail: '尚未取得可信的服务状态', streamPortDetail: '端口 {port}', + streamRetryStatus: '重新获取状态', + streamRetryCrop: '重新加载配置', streamCopied: 'OBS 地址已复制', streamCopyFailed: '复制失败,请手动选择文本复制', streamCropSaved: '裁切代码已保存', streamCropReset: '裁切设置已恢复默认', + streamProblemServiceFailed: + '叠加层服务未能启动。请确认端口 17654 可用后重试。', + streamProblemRestartFailed: + '叠加层服务重启失败。请确认端口 17654 可用后重试。', + streamProblemPollFailed: + '暂时无法确认叠加层的最新状态;上次状态已标记为过期,请重新获取。', + streamProblemWindowFailed: '无法调整展示窗口,请重试。', + streamProblemCropLoadFailed: + '无法加载叠加层配置;其他直播控制仍可使用,请重新加载。', + streamProblemCropSaveFailed: '无法保存叠加层配置;请检查裁切代码并重试。', + streamProblemOpenOverlayFailed: '无法打开叠加层预览,请重试。', + streamProblemOpenSettingsFailed: '无法打开叠加层校准页,请重试。', + streamProblemUnexpected: '处理直播功能时发生意外错误,请重试。', dbConnected: '数据库已连接', dbMissing: '数据库未找到', @@ -601,13 +621,39 @@ const en: Record = { streamStatusStarting: 'Overlay Starting', streamStatusRunning: 'Overlay Running', streamStatusIdle: 'Overlay Idle', + streamStatusStale: 'Overlay Status May Be Stale', + streamStatusUnavailable: 'Overlay Status Unavailable', streamStarting: 'Starting local service', streamIdleDetail: 'Service not started', + streamStaleRunningDetail: + 'Last seen running; waiting for an up-to-date service status', + streamStaleIdleDetail: + 'Last seen stopped; waiting for an up-to-date service status', + streamStatusUnavailableDetail: 'No authoritative service status is available', streamPortDetail: 'Port {port}', + streamRetryStatus: 'Refresh Status', + streamRetryCrop: 'Reload Config', streamCopied: 'OBS URL copied', streamCopyFailed: 'Copy failed. Select the text and copy manually.', streamCropSaved: 'Crop code saved', streamCropReset: 'Crop settings reset to default', + streamProblemServiceFailed: + 'The overlay service could not start. Make sure port 17654 is available, then retry.', + streamProblemRestartFailed: + 'The overlay service could not restart. Make sure port 17654 is available, then retry.', + streamProblemPollFailed: + 'The latest overlay status could not be confirmed. The previous value is marked stale; refresh it.', + streamProblemWindowFailed: 'The display window could not be changed. Retry.', + streamProblemCropLoadFailed: + 'Overlay configuration could not be loaded. Other stream controls remain available; reload it.', + streamProblemCropSaveFailed: + 'Overlay configuration could not be saved. Check the crop code and retry.', + streamProblemOpenOverlayFailed: + 'The overlay preview could not be opened. Retry.', + streamProblemOpenSettingsFailed: + 'The overlay calibration page could not be opened. Retry.', + streamProblemUnexpected: + 'Something unexpected happened while handling Stream. Please retry.', dbConnected: 'DB Connected', dbMissing: 'DB Missing', diff --git a/src/pages/Stream.tsx b/src/pages/Stream.tsx index cf07b9c..89518a9 100644 --- a/src/pages/Stream.tsx +++ b/src/pages/Stream.tsx @@ -1,5 +1,6 @@ import { AlertCircle, + AlertTriangle, Copy, ExternalLink, Maximize, @@ -10,7 +11,14 @@ import { } from 'lucide-react'; import type { StreamOverlayDisplayMode } from '../types/backend'; import { PageShell } from '../components/ui/PageShell'; +import { ProblemBanner } from '../components/ui/ProblemBanner'; import { useStreamPage } from '../features/stream/useStreamPage'; +import { + presentStreamProblem, + presentStreamSnapshot +} from '../features/stream/streamPresentation'; +import type { StreamProblem } from '../features/stream/streamProblems'; +import { formatProblemDiagnostic } from '../features/shared/problems'; import { useI18n } from '../i18n/LocaleProvider'; import type { MessageKey } from '../i18n/messages'; @@ -26,8 +34,10 @@ const displayModes: Array<{ export default function Stream() { const { t } = useI18n(); const { snapshot, intents } = useStreamPage(); - const { status, cropSettings } = snapshot; - const feedbackIsError = snapshot.feedback?.tone === 'error'; + const presentation = presentStreamSnapshot(snapshot, t); + const status = snapshot.service.status; + const cropSettings = snapshot.crop.settings; + const statusTone = presentation.status.tone; return ( @@ -37,38 +47,49 @@ export default function Stream() {
- {snapshot.phase === 'error' ? ( + {statusTone === 'degraded' ? ( - ) : status.running ? ( + ) : statusTone === 'stale' ? ( + + ) : statusTone === 'running' ? ( ) : ( )}

- {snapshot.statusLabel} + {presentation.status.label}

- {snapshot.statusDetail} - {status.running ? ` · ${snapshot.dbLabel}` : ''} + {presentation.status.detail} + {status?.running && snapshot.polling.freshness === 'fresh' + ? ` · ${presentation.dbLabel}` + : ''}

+ {snapshot.service.problem && ( + void intents.restart()} + /> + )} + {snapshot.polling.problem && ( + void intents.retryStatus()} + /> + )} + {presentation.notice && ( +

+ {presentation.notice} +

+ )} +
@@ -106,29 +154,24 @@ export default function Stream() { className="flex-1 px-3 py-2 bg-[rgba(0,0,0,0.4)] border border-[rgba(180,130,48,0.2)] rounded-sm fira-code text-sm text-[rgba(228,216,191,0.8)] overflow-hidden text-ellipsis whitespace-nowrap selectable" aria-labelledby="stream-obs-url-label" > - {snapshot.obsUrl ?? t('streamObsPlaceholder')} + {snapshot.oneOff.obsUrl ?? t('streamObsPlaceholder')}
- {snapshot.feedback && ( -

- {snapshot.feedback.text} -

+ {snapshot.oneOff.problems.copy && ( + + )} + {snapshot.oneOff.problems.open_overlay && ( + )} @@ -139,12 +182,12 @@ export default function Stream() {
- {snapshot.windowLabel} + {presentation.windowLabel}
+ {snapshot.window.problem && ( + + )} +
- + + -
@@ -183,6 +236,28 @@ export default function Stream() { {t('streamOverlayConfig')} + {snapshot.crop.problem && ( + void intents.reloadCropSettings() + : undefined + } + /> + )} + {snapshot.oneOff.problems.open_settings && ( + + )} +
{displayModes.map((mode) => (