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) {
{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);