Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions ui/src/features/cameras/components/CameraPreviewPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ describe('CameraPreviewPanel', () => {
},
})
render(<CameraPreviewPanel cameraName="front" />)
screen.getByRole('button', { name: 'Release to stop' }).focus()
screen.getByRole('button', { name: 'Talking' }).focus()
await user.keyboard('[Space>]')
await user.keyboard('[/Space]')

Expand Down Expand Up @@ -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()
})
})

Expand All @@ -599,7 +600,10 @@ describe('CameraPreviewPanel', () => {
// When: Rendering the preview controls
render(<CameraPreviewPanel cameraName="front" />)

// 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()
Expand Down Expand Up @@ -628,7 +632,10 @@ describe('CameraPreviewPanel', () => {
// When: Rendering the preview controls
render(<CameraPreviewPanel cameraName="front" />)

// 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()
Expand Down Expand Up @@ -659,10 +666,32 @@ describe('CameraPreviewPanel', () => {
// When: Rendering the preview controls
render(<CameraPreviewPanel cameraName="front" />)

// 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(<CameraPreviewPanel cameraName="front" />)

// 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)
})

})
230 changes: 168 additions & 62 deletions ui/src/features/cameras/components/PushToTalkControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,70 @@ 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'

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
}
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -157,16 +250,19 @@ export function PushToTalkControl({ cameraName }: PushToTalkControlProps) {
<header className="push-to-talk__header">
<div>
<p className="camera-preview__title">Push to talk</p>
<p className="camera-preview__subtitle">Hold the mic button to stream browser audio to the camera speaker.</p>
<p className="camera-preview__subtitle">Hold the button to speak through this camera.</p>
</div>
<StatusBadge tone={talkTone(state)}>{talkLabel(state)}</StatusBadge>
<StatusBadge tone={talkTone(talkState)}>{talkLabel(talkState)}</StatusBadge>
</header>

{message ? <p className="camera-preview__message">{message}</p> : null}
{talkState === 'talking' ? (
<p className="push-to-talk__hint">Talking. Release to stop.</p>
) : null}

<div className="inline-form__actions">
<div className="inline-form__actions push-to-talk__actions">
<Button
className={isStreaming ? 'push-to-talk__button push-to-talk__button--active' : 'push-to-talk__button'}
className={talkButtonClassName}
aria-pressed={isStarting || isStreaming}
disabled={disabled}
onPointerDown={(event) => {
Expand Down Expand Up @@ -218,7 +314,7 @@ export function PushToTalkControl({ cameraName }: PushToTalkControlProps) {
endTalk()
}}
>
{buttonLabel(isStarting, isStreaming, isStopping)}
{buttonLabel(talkState)}
</Button>
<Button
variant="ghost"
Expand All @@ -230,6 +326,16 @@ export function PushToTalkControl({ cameraName }: PushToTalkControlProps) {
Refresh talk
</Button>
</div>

{details.length > 0 ? (
<TechnicalDetailsDisclosure summary="Technical talk details">
<ul className="technical-details__list">
{details.map((detail) => (
<li key={detail}>{detail}</li>
))}
</ul>
</TechnicalDetailsDisclosure>
) : null}
</section>
)
}
Loading
Loading