diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index a5905916..0d8fef4c 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -515,7 +515,7 @@ describe('CameraPreviewPanel', () => { }, }) render() - screen.getByRole('button', { name: 'Release to stop' }).focus() + screen.getByRole('button', { name: 'Talking' }).focus() await user.keyboard('[Space>]') await user.keyboard('[/Space]') @@ -572,7 +572,8 @@ describe('CameraPreviewPanel', () => { const video = container.querySelector('video') expect(video?.muted).toBe(true) expect(video?.getAttribute('muted')).toBe('') - expect(screen.getByText('TALKING')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Talking' })).toBeTruthy() + expect(screen.getByText('Talking. Release to stop.')).toBeTruthy() }) }) @@ -599,7 +600,10 @@ describe('CameraPreviewPanel', () => { // When: Rendering the preview controls render() - // Then: The talk control shows codec-specific diagnostics instead of generic copy + // Then: The talk control keeps user-facing copy simple and hides codec diagnostics by default + expect(screen.getByRole('button', { name: 'Talk unavailable' })).toBeTruthy() + expect(screen.getByText('Talk unavailable on this camera.')).toBeTruthy() + expect(screen.getByText('Technical talk details')).toBeTruthy() expect(screen.getByText( 'Camera talkback codec is not supported. Offered: OPUS/48000. Supported: PCMU/8000, PCMA/8000.', )).toBeTruthy() @@ -628,7 +632,10 @@ describe('CameraPreviewPanel', () => { // When: Rendering the preview controls render() - // Then: The talk control shows the probe failure message + // Then: The talk control keeps probe failure details under technical disclosure + expect(screen.getByRole('button', { name: 'Talk unavailable' })).toBeTruthy() + expect(screen.getByText('Talk unavailable on this camera.')).toBeTruthy() + expect(screen.getByText('Technical talk details')).toBeTruthy() expect(screen.getByText( 'Talkback capability check failed: RTSP authentication was rejected by the camera', )).toBeTruthy() @@ -659,10 +666,32 @@ describe('CameraPreviewPanel', () => { // When: Rendering the preview controls render() - // Then: The talk control shows the operator-actionable config message + // Then: The talk control keeps backend config details under technical disclosure + expect(screen.getByRole('button', { name: 'Talk unavailable' })).toBeTruthy() + expect(screen.getByText('Talk unavailable on this camera.')).toBeTruthy() + expect(screen.getByText('Technical talk details')).toBeTruthy() expect(screen.getByText( "Talk backend 'onvif_rtsp_backchannel' config is invalid", )).toBeTruthy() }) + it('shows a clear blocked-microphone state', () => { + // Given: Browser microphone permission was denied + const blockedError = Object.assign(new Error('Permission denied'), { + name: 'NotAllowedError', + }) + mockReadyPreviewSession() + mockIdlePushToTalk({ canStart: false, error: blockedError }) + + // When: Rendering the preview controls + render() + + // Then: The talk control explains the browser permission state in homeowner terms + expect(screen.getByRole('button', { name: 'Microphone blocked' })).toBeTruthy() + expect(screen.getByText( + 'Microphone blocked. Allow microphone access in your browser and try again.', + )).toBeTruthy() + expect(screen.getByRole('button', { name: 'Microphone blocked' }).hasAttribute('disabled')).toBe(true) + }) + }) diff --git a/ui/src/features/cameras/components/PushToTalkControl.tsx b/ui/src/features/cameras/components/PushToTalkControl.tsx index 1da827af..53b97141 100644 --- a/ui/src/features/cameras/components/PushToTalkControl.tsx +++ b/ui/src/features/cameras/components/PushToTalkControl.tsx @@ -4,6 +4,7 @@ import { isAPIError } from '../../../api/client' import type { TalkStatusResponse } from '../../../api/generated/types' import { Button } from '../../../components/ui/Button' import { StatusBadge } from '../../../components/ui/StatusBadge' +import { TechnicalDetailsDisclosure } from '../../../components/ui/TechnicalDetailsDisclosure' import { describeUnknownError } from '../../shared/errorPresentation' import { usePushToTalk } from '../hooks/usePushToTalk' @@ -11,62 +12,62 @@ interface PushToTalkControlProps { cameraName: string } -function talkTone(state: string | undefined): 'healthy' | 'degraded' | 'unhealthy' | 'unknown' { +type TalkUiState = 'idle' | 'connecting' | 'talking' | 'microphone_blocked' | 'unavailable' | 'stopping' + +function talkTone(state: TalkUiState): 'healthy' | 'degraded' | 'unhealthy' | 'unknown' { switch (state) { - case 'active': + case 'talking': return 'healthy' - case 'starting': + case 'connecting': case 'stopping': - case 'temporarily_unavailable': return 'degraded' - case 'disabled': - case 'unsupported': - case 'error': + case 'microphone_blocked': + case 'unavailable': return 'unhealthy' default: return 'unknown' } } -function talkLabel(state: string | undefined): string { +function talkLabel(state: TalkUiState): string { switch (state) { - case 'active': - return 'TALKING' - case 'starting': - return 'CONNECTING' + case 'talking': + return 'Talking' + case 'connecting': + return 'Connecting' case 'stopping': - return 'STOPPING' - case 'disabled': - return 'DISABLED' - case 'unsupported': - return 'UNSUPPORTED' - case 'temporarily_unavailable': - return 'BUSY' - case 'error': - return 'ERROR' + return 'Stopping' + case 'microphone_blocked': + return 'Microphone blocked' + case 'unavailable': + return 'Talk unavailable' default: - return 'IDLE' + return 'Hold to talk' } } -function buttonLabel(isStarting: boolean, isStreaming: boolean, isStopping: boolean): string { - if (isStopping) { - return 'Stopping...' - } - if (isStarting) { - return 'Connecting...' - } - if (isStreaming) { - return 'Release to stop' +function buttonLabel(state: TalkUiState): string { + switch (state) { + case 'connecting': + return 'Connecting...' + case 'talking': + return 'Talking' + case 'microphone_blocked': + return 'Microphone blocked' + case 'unavailable': + return 'Talk unavailable' + case 'stopping': + return 'Stopping...' + default: + return 'Hold to talk' } - return 'Hold to talk' } function codecList(codecs: string[] | undefined): string { return codecs && codecs.length > 0 ? codecs.join(', ') : 'none' } -function capabilityMessage(status: TalkStatusResponse | null): string | null { +function technicalCapabilityMessage(status: TalkStatusResponse | null): string | null { if (!status) { return null } @@ -89,27 +90,125 @@ function capabilityMessage(status: TalkStatusResponse | null): string | null { } } -function blockedMessage(status: TalkStatusResponse | null, state: string | undefined): string | null { - const capability = capabilityMessage(status) - if (capability) { - return capability +function isMicrophoneBlocked(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const name = 'name' in error && typeof error.name === 'string' ? error.name : '' + const message = 'message' in error && typeof error.message === 'string' ? error.message : '' + return ( + name === 'NotAllowedError' + || name === 'PermissionDeniedError' + || name === 'SecurityError' + || /permission denied|notallowed|not allowed/i.test(message) + ) +} + +function isTalkUnavailable(status: TalkStatusResponse | null, state: string | undefined): boolean { + if (!status) { + return false + } + if ( + status.capability === 'disabled' + || status.capability === 'unsupported' + || status.capability === 'unsupported_codec' + || status.capability === 'config_error' + || status.capability === 'error' + ) { + return true + } + return state === 'disabled' || state === 'unsupported' || state === 'error' +} + +function uiState({ + error, + isStarting, + isStopping, + isStreaming, + status, + state, +}: { + error: unknown + isStarting: boolean + isStopping: boolean + isStreaming: boolean + status: TalkStatusResponse | null + state: string | undefined +}): TalkUiState { + if (isMicrophoneBlocked(error)) { + return 'microphone_blocked' + } + if (isStreaming) { + return 'talking' + } + if (isStarting || state === 'starting') { + return 'connecting' + } + if (isStopping || state === 'stopping') { + return 'stopping' + } + if (isTalkUnavailable(status, state) || state === 'temporarily_unavailable' || state === 'active') { + return 'unavailable' + } + return 'idle' +} + +function userMessage({ + error, + isPending, + status, + state, + talkState, +}: { + error: unknown + isPending: boolean + status: TalkStatusResponse | null + state: string | undefined + talkState: TalkUiState +}): string | null { + if (talkState === 'microphone_blocked') { + return 'Microphone blocked. Allow microphone access in your browser and try again.' + } + if (error) { + if (isAPIError(error) && error.errorCode === 'TALK_SESSION_ALREADY_ACTIVE') { + return 'Another talk session is already active.' + } + return describeUnknownError(error) + } + if (isPending) { + return 'Checking talk availability...' + } + if (talkState === 'talking') { + return null } switch (state) { - case 'disabled': - return 'Push-to-talk is disabled for this camera.' - case 'unsupported': - return 'This camera source does not support talkback.' case 'temporarily_unavailable': - return 'Talkback is temporarily unavailable. Try again in a moment.' + return 'Talk is busy. Try again in a moment.' case 'active': return 'Another talk session is already active.' - case 'error': - return status?.last_error ?? 'Talkback is in an error state.' default: - return null + return isTalkUnavailable(status, state) ? 'Talk unavailable on this camera.' : null } } +function technicalDetails(status: TalkStatusResponse | null, error: unknown): string[] { + const details: string[] = [] + const capability = technicalCapabilityMessage(status) + if (capability) { + details.push(capability) + } + if (status?.backend_reason) { + details.push(`Backend detail: ${status.backend_reason}`) + } + if (status?.selected_codec) { + details.push(`Selected codec: ${status.selected_codec}`) + } + if (error && !isMicrophoneBlocked(error)) { + details.push(`Last error: ${describeUnknownError(error)}`) + } + return [...new Set(details)] +} + export function PushToTalkControl({ cameraName }: PushToTalkControlProps) { const { canStart, @@ -128,18 +227,12 @@ export function PushToTalkControl({ cameraName }: PushToTalkControlProps) { const pointerHeldRef = useRef(false) const disabled = !canStart && !isStarting && !isStreaming const state = isStreaming ? 'active' : (status?.state ?? session?.state) - const message = (() => { - if (error) { - if (isAPIError(error) && error.errorCode === 'TALK_SESSION_ALREADY_ACTIVE') { - return 'Another talk session is already active.' - } - return describeUnknownError(error) - } - if (isPending) { - return 'Checking talkback capability...' - } - return blockedMessage(status, state) - })() + const talkState = uiState({ error, isStarting, isStopping, isStreaming, status, state }) + const message = userMessage({ error, isPending, status, state, talkState }) + const details = technicalDetails(status, error) + const talkButtonClassName = talkState === 'talking' + ? 'push-to-talk__button push-to-talk__button--active' + : 'push-to-talk__button' const beginTalk = (): void => { if (!canStart) { @@ -157,16 +250,19 @@ export function PushToTalkControl({ cameraName }: PushToTalkControlProps) {

Push to talk

-

Hold the mic button to stream browser audio to the camera speaker.

+

Hold the button to speak through this camera.

- {talkLabel(state)} + {talkLabel(talkState)}
{message ?

{message}

: null} + {talkState === 'talking' ? ( +

Talking. Release to stop.

+ ) : null} -
+
+ + {details.length > 0 ? ( + +
    + {details.map((detail) => ( +
  • {detail}
  • + ))} +
+
+ ) : null} ) } diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index 1b7cd893..b971205e 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -990,11 +990,11 @@ a:hover { .push-to-talk { display: grid; - gap: var(--space-2); + gap: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--surface-1) 58%, transparent); - padding: 0.65rem 0.7rem; + padding: var(--space-3); } .push-to-talk__header { @@ -1004,8 +1004,22 @@ a:hover { gap: var(--space-2); } +.push-to-talk__hint { + margin: 0; + color: var(--danger); + font-weight: 650; +} + +.push-to-talk__actions { + align-items: stretch; +} + .push-to-talk__button { - min-width: 9.5rem; + min-height: 3.75rem; + min-width: min(100%, 12rem); + padding-inline: var(--space-4); + font-size: 1rem; + font-weight: 750; touch-action: none; user-select: none; } @@ -1020,6 +1034,17 @@ a:hover { background: color-mix(in srgb, var(--danger) 84%, #000); } +.technical-details__list { + margin: 0; + padding-left: 1.1rem; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.technical-details__list li + li { + margin-top: 0.35rem; +} + .camera-config-editor { display: grid; gap: var(--space-2);