Skip to content

Commit 387e24a

Browse files
authored
fix(app): refetch the livestream on error while a protocol run isn't terminal (#20023)
Closes RQA-4812
1 parent fb0debd commit 387e24a

File tree

3 files changed

+71
-38
lines changed

3 files changed

+71
-38
lines changed

app/src/pages/Desktop/LivestreamViewer/LivestreamInfoScreen.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { InfoScreen } from '@opentrons/components'
44
import { useCamera } from '@opentrons/react-api-client'
55

66
import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils'
7-
import { useNotifyRunQuery } from '/app/resources/runs'
7+
8+
import type { RunStatus } from '@opentrons/api-client'
89

910
const CAMERA_POLLING_INTERVAL_MS = 5000
1011

@@ -16,22 +17,21 @@ type LiveStreamInfoScreenType =
1617
| null
1718

1819
export function useLivestreamInfoScreen(
19-
runId: string,
20+
runStatus: RunStatus | null,
21+
isRunLoading: boolean,
2022
videoError: string | null
2123
): LiveStreamInfoScreenType {
22-
const { data: runData, isLoading: isRunLoading } = useNotifyRunQuery(runId)
2324
const { data: cameraData, isLoading: isCameraSettingsLoading } = useCamera({
2425
refetchInterval: CAMERA_POLLING_INTERVAL_MS,
2526
})
26-
const runStatus = runData?.data.status ?? null
2727
const isCameraEnabled = cameraData?.cameraEnabled ?? false
2828

29-
if (videoError != null) {
29+
if (isTerminalRunStatus(runStatus)) {
30+
return 'run-terminal'
31+
} else if (videoError != null) {
3032
return 'error'
3133
} else if (isRunLoading || isCameraSettingsLoading) {
3234
return 'loading'
33-
} else if (isTerminalRunStatus(runStatus)) {
34-
return 'run-terminal'
3535
} else if (!isCameraEnabled) {
3636
return 'disabled'
3737
} else {

app/src/pages/Desktop/LivestreamViewer/hooks/useHlsVideo.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,37 @@ import Hls from 'hls.js'
33

44
import { useHost } from '@opentrons/react-api-client'
55

6+
import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils'
7+
68
import type { RefObject } from 'react'
9+
import type { RunStatus } from '@opentrons/api-client'
710

811
// TODO(jh, 09-05-25): /GET this from the /stream endpoint eventually.
912
const STREAM_URL = (robotIp: string): string =>
1013
`http://${robotIp}:31950/hls/stream.m3u8`
1114

15+
const RETRY_DELAY_MS = 3000
16+
1217
export interface UseHlsVideoResult {
1318
videoRef: RefObject<HTMLVideoElement>
1419
videoError: string | null
1520
}
1621

17-
// Sets up and manages an HLS video stream player that receives
18-
// camera stream URLs from the main Electron process and displays them.
19-
export function useHlsVideo(): UseHlsVideoResult {
22+
export function useHlsVideo(runStatus: RunStatus | null): UseHlsVideoResult {
2023
const host = useHost()
2124
const videoRef = useRef<HTMLVideoElement>(null)
2225
const hlsRef = useRef<Hls | null>(null)
2326
const [error, setError] = useState<string | null>(null)
27+
const retryCountRef = useRef(0)
28+
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null)
29+
30+
const setupHls = useRef<() => void>()
31+
32+
useEffect(() => {
33+
if (error) {
34+
console.error(error)
35+
}
36+
}, [error])
2437

2538
useEffect(() => {
2639
if (videoRef.current == null || host == null) {
@@ -29,7 +42,12 @@ export function useHlsVideo(): UseHlsVideoResult {
2942

3043
const video = videoRef.current
3144

32-
if (Hls.isSupported()) {
45+
setupHls.current = () => {
46+
if (!Hls.isSupported()) {
47+
setError('HLS streaming not supported in this browser.')
48+
return
49+
}
50+
3351
if (hlsRef.current) {
3452
hlsRef.current.destroy()
3553
}
@@ -54,28 +72,25 @@ export function useHlsVideo(): UseHlsVideoResult {
5472
hls.attachMedia(video)
5573

5674
hls.on(Hls.Events.MANIFEST_PARSED, () => {
75+
retryCountRef.current = 0
76+
setError(null)
5777
video.play().catch(e => {
5878
console.error('Auto-play failed:', e)
5979
setError('Auto-play failed. Please close and reopen the stream.')
6080
})
6181
})
6282

63-
hls.on(Hls.Events.ERROR, (event, data) => {
64-
console.error('HLS error:', event, data)
65-
if (data.fatal) {
66-
switch (data.type) {
67-
case Hls.ErrorTypes.NETWORK_ERROR:
68-
setError(
69-
`Network error: Cannot connect to ${STREAM_URL(host.hostname)}`
70-
)
71-
break
72-
case Hls.ErrorTypes.MEDIA_ERROR:
73-
setError('Media error in stream')
74-
break
75-
default:
76-
setError('Fatal error loading stream')
77-
break
78-
}
83+
hls.on(Hls.Events.ERROR, (_, data) => {
84+
// `fatal` prevents refetching over-aggressively, ex, when the buffer is temporarily depleted.
85+
if (data.fatal && !isTerminalRunStatus(runStatus)) {
86+
retryCountRef.current++
87+
setError(
88+
`Service unavailable. Retrying (${retryCountRef.current})...`
89+
)
90+
91+
retryTimeoutRef.current = setTimeout(() => {
92+
setupHls.current?.()
93+
}, RETRY_DELAY_MS)
7994
}
8095
})
8196

@@ -84,18 +99,23 @@ export function useHlsVideo(): UseHlsVideoResult {
8499
}
85100

86101
video.addEventListener('error', handleVideoError)
102+
}
87103

88-
return () => {
89-
video.removeEventListener('error', handleVideoError)
90-
if (hlsRef.current) {
91-
hlsRef.current.destroy()
92-
hlsRef.current = null
93-
}
104+
setupHls.current()
105+
106+
return () => {
107+
if (retryTimeoutRef.current) {
108+
clearTimeout(retryTimeoutRef.current)
109+
}
110+
111+
if (hlsRef.current) {
112+
hlsRef.current.destroy()
113+
hlsRef.current = null
94114
}
95-
} else {
96-
setError('HLS streaming not supported in this browser.')
115+
116+
retryCountRef.current = 0
97117
}
98-
}, [host])
118+
}, [host, runStatus])
99119

100120
return { videoRef, videoError: error }
101121
}

app/src/pages/Desktop/LivestreamViewer/index.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next'
22
import { useSearchParams } from 'react-router-dom'
33

44
import { Chip } from '@opentrons/components'
5+
// eslint-disable-next-line no-restricted-imports
6+
import { useRunQuery } from '@opentrons/react-api-client'
57

68
import { useHlsVideo } from '/app/pages/Desktop/LivestreamViewer/hooks/useHlsVideo'
79
import {
@@ -11,11 +13,22 @@ import {
1113

1214
import styles from './livestream.module.css'
1315

16+
const RUN_POLLING_INTERVAL_MS = 5000
17+
1418
export function LivestreamViewer(): JSX.Element {
15-
const { videoRef, videoError } = useHlsVideo()
1619
const [searchParams] = useSearchParams()
1720
const runId = searchParams.get('runId') ?? ''
18-
const infoScreenType = useLivestreamInfoScreen(runId, videoError)
21+
// TODO(jh, 11-04-25): Notifications are not working in secondary windows. Investigate further.
22+
const { data: runData, isLoading: isRunLoading } = useRunQuery(runId, {
23+
refetchInterval: RUN_POLLING_INTERVAL_MS,
24+
})
25+
const runStatus = runData?.data.status ?? null
26+
const { videoRef, videoError } = useHlsVideo(runStatus)
27+
const infoScreenType = useLivestreamInfoScreen(
28+
runStatus,
29+
isRunLoading,
30+
videoError
31+
)
1932

2033
return (
2134
<div className={styles.container}>

0 commit comments

Comments
 (0)