From 1e4176ef921556caf1a29b02bb7e23930489ce4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ant=C3=B4nio=20Cardoso?= Date: Tue, 10 Mar 2026 09:55:24 -0300 Subject: [PATCH 1/4] frontend: components: video-manager: VideoThumbnail: Debounce stop and fix blob URL cleanup Debounce stopGetThumbnailForDevice by 15 seconds to ride through transient backend restarts without interrupting thumbnail polling. Move blob URL revocation to beforeDestroy to prevent premature cleanup. --- .../components/video-manager/VideoThumbnail.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/frontend/src/components/video-manager/VideoThumbnail.vue b/core/frontend/src/components/video-manager/VideoThumbnail.vue index d823104e32..0dd139292e 100644 --- a/core/frontend/src/components/video-manager/VideoThumbnail.vue +++ b/core/frontend/src/components/video-manager/VideoThumbnail.vue @@ -63,6 +63,7 @@ export default Vue.extend({ return { thumbnail: undefined as undefined | Thumbnail, update_task: new OneMoreTime({ delay: 1000, disposeWith: this, autostart: true }), + stopDebounceTimer: undefined as ReturnType | undefined, } }, computed: { @@ -76,8 +77,15 @@ export default Vue.extend({ watch: { register(newValue, oldValue) { if (!newValue && oldValue) { - video.stopGetThumbnailForDevice(this.source) + clearTimeout(this.stopDebounceTimer) + this.stopDebounceTimer = setTimeout(() => { + if (!this.register) { + video.stopGetThumbnailForDevice(this.source) + } + }, 15000) } else if (newValue && !oldValue) { + clearTimeout(this.stopDebounceTimer) + this.stopDebounceTimer = undefined video.startGetThumbnailForDevice(this.source) } }, @@ -89,6 +97,11 @@ export default Vue.extend({ this.update_task.setAction(this.updateThumbnail) }, beforeDestroy() { + clearTimeout(this.stopDebounceTimer) + const blobUrl = video.thumbnails.get(this.source)?.source + if (blobUrl !== undefined) { + URL.revokeObjectURL(blobUrl) + } video.stopGetThumbnailForDevice(this.source) }, methods: { From 9b8ecf5fce7aa4f85af083e8541e15db7fd8aba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ant=C3=B4nio=20Cardoso?= Date: Tue, 10 Mar 2026 09:55:31 -0300 Subject: [PATCH 2/4] frontend: store: video: Use HMR-safe singleton for thumbnail polling state Move thumbnail sources, busy set, and OneMoreTime task to a window-global singleton that persists across hot module reloads. Add reentrant guard to prevent duplicate fetchThumbnails execution from the double @Module decorator. Reduce polling delay to 100ms and preserve last valid thumbnail on 503 errors. --- core/frontend/src/store/video.ts | 82 +++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/core/frontend/src/store/video.ts b/core/frontend/src/store/video.ts index 376bf7715e..345c05489a 100644 --- a/core/frontend/src/store/video.ts +++ b/core/frontend/src/store/video.ts @@ -16,10 +16,28 @@ import back_axios, { isBackendOffline } from '@/utils/api' export interface Thumbnail { source: string | undefined status: number | undefined + roundtripMs: number | undefined } const notifier = new Notifier(video_manager_service) +interface ThumbnailFetchState { + task: OneMoreTime + sources: Set + busy: Set + inProgress: boolean +} + +const THUMBNAIL_STATE_KEY = '__blueos_video_thumbnail_state__' +const thumbnailState: ThumbnailFetchState = (window as any)[THUMBNAIL_STATE_KEY] ??= { + task: new OneMoreTime({ delay: 1000, autostart: false }), + sources: new Set(), + busy: new Set(), + inProgress: false, +} +;(window as any)[THUMBNAIL_STATE_KEY] = thumbnailState +thumbnailState.task.setDelay(1000) + @Module({ dynamic: true, store, @@ -43,14 +61,6 @@ class VideoStore extends VuexModule { thumbnails: Map = new Map() - private sources_to_request_thumbnail: Set = new Set() - - private busy_sources: Set = new Set() - - fetchThumbnailsTask = new OneMoreTime( - { delay: 1000, autostart: false }, - ) - @Mutation setUpdatingStreams(updating: boolean): void { this.updating_streams = updating @@ -174,17 +184,21 @@ class VideoStore extends VuexModule { @Action async fetchThumbnails(): Promise { + if (thumbnailState.inProgress) return + thumbnailState.inProgress = true + const target_height = 150 const quality = 75 const requests: Promise[] = [] - this.sources_to_request_thumbnail.forEach(async (source: string) => { - if (this.busy_sources.has(source)) { + thumbnailState.sources.forEach(async (source: string) => { + if (thumbnailState.busy.has(source)) { return } - this.busy_sources.add(source) + thumbnailState.busy.add(source) + const requestStart = Date.now() const request = back_axios({ method: 'get', url: `${this.API_URL}/thumbnail?source=${source}&quality=${quality}&target_height=${target_height}`, @@ -193,29 +207,42 @@ class VideoStore extends VuexModule { }) .then((response) => { if (response.status === 200) { + const roundtripMs = Date.now() - requestStart const old_thumbnail_source = this.thumbnails.get(source)?.source if (old_thumbnail_source !== undefined) { URL.revokeObjectURL(old_thumbnail_source) } - this.thumbnails.set(source, { source: URL.createObjectURL(response.data), status: response.status }) + this.thumbnails.set(source, { + source: URL.createObjectURL(response.data), status: response.status, roundtripMs, + }) } }) .catch((error) => { + const roundtripMs = Date.now() - requestStart if (error?.response?.status === StatusCodes.SERVICE_UNAVAILABLE) { - this.thumbnails.set(source, { source: undefined, status: error.response.status }) + const existing = this.thumbnails.get(source) + if (existing?.source) { + this.thumbnails.set(source, { source: existing.source, status: error.response.status, roundtripMs }) + } else { + this.thumbnails.set(source, { source: undefined, status: error.response.status, roundtripMs }) + } } else { this.thumbnails.delete(source) } }) .finally(() => { - this.busy_sources.delete(source) + thumbnailState.busy.delete(source) }) requests.push(request) }) - await Promise.allSettled(requests) + try { + await Promise.allSettled(requests) + } finally { + thumbnailState.inProgress = false + } } @Action @@ -236,26 +263,25 @@ class VideoStore extends VuexModule { }) } + // eslint-disable-next-line class-methods-use-this @Action startGetThumbnailForDevice(source: string): void { - if (this.sources_to_request_thumbnail.size > 0) { - this.fetchThumbnailsTask.resume() - } else { - this.fetchThumbnailsTask.start() + thumbnailState.sources.add(source) + const task = thumbnailState.task as any + if (task.isPaused) { + thumbnailState.task.resume() + } else if (!task.isRunning && !task.timeoutId) { + thumbnailState.task.start() } - this.sources_to_request_thumbnail.add(source) } + // eslint-disable-next-line class-methods-use-this @Action stopGetThumbnailForDevice(source: string): void { - const old_thumbnail_source = this.thumbnails.get(source)?.source - if (old_thumbnail_source !== undefined) { - URL.revokeObjectURL(old_thumbnail_source) - } - this.sources_to_request_thumbnail.delete(source) + thumbnailState.sources.delete(source) - if (this.sources_to_request_thumbnail.size === 0) { - this.fetchThumbnailsTask.stop() + if (thumbnailState.sources.size === 0) { + thumbnailState.task.stop() } } } @@ -264,6 +290,6 @@ export { VideoStore } const video: VideoStore = getModule(VideoStore) -video.fetchThumbnailsTask.setAction(video.fetchThumbnails) +thumbnailState.task.setAction(video.fetchThumbnails) export default video From 0180bcc080536b678432844945bb4bd3d7cd00fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ant=C3=B4nio=20Cardoso?= Date: Tue, 10 Mar 2026 14:53:34 -0300 Subject: [PATCH 3/4] frontend: components: video-manager: VideoThumbnail: Add manual thumbnail controls Default to paused thumbnail fetching to avoid unnecessary resource usage. Add snapshot (single-fetch) and play/pause (continuous 1s polling) controls. Show a warning that thumbnails use stream resources and may affect video quality. Replace v-avatar with a 16:9 aspect ratio frame for consistent placeholder and image dimensions. --- .../video-manager/VideoControlsDialog.vue | 6 +- .../video-manager/VideoThumbnail.vue | 210 ++++++++++++++++-- 2 files changed, 191 insertions(+), 25 deletions(-) diff --git a/core/frontend/src/components/video-manager/VideoControlsDialog.vue b/core/frontend/src/components/video-manager/VideoControlsDialog.vue index 3bcaa8560b..b18ab9b51f 100644 --- a/core/frontend/src/components/video-manager/VideoControlsDialog.vue +++ b/core/frontend/src/components/video-manager/VideoControlsDialog.vue @@ -15,9 +15,9 @@ > @@ -126,6 +126,10 @@ export default Vue.extend({ type: Boolean, default: true, }, + thumbnailDisabled: { + type: Boolean, + default: false, + }, }, data() { return { diff --git a/core/frontend/src/components/video-manager/VideoThumbnail.vue b/core/frontend/src/components/video-manager/VideoThumbnail.vue index 0dd139292e..72bbe68331 100644 --- a/core/frontend/src/components/video-manager/VideoThumbnail.vue +++ b/core/frontend/src/components/video-manager/VideoThumbnail.vue @@ -1,38 +1,115 @@ + + From 88537159dd01d54490a7dcd21c06c36e810dd247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ant=C3=B4nio=20Cardoso?= Date: Tue, 10 Mar 2026 16:12:45 -0300 Subject: [PATCH 4/4] frontend: components: video-manager: VideoStreamCreationDialog: Add extended config booleans and fix form state reset Add disable_lazy, disable_thumbnails, and disable_zenoh checkboxes to the extra configuration panel. Fix stale form state on dialog reopen by re-syncing all fields from the stream prop via a watcher. --- .../VideoStreamCreationDialog.vue | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/core/frontend/src/components/video-manager/VideoStreamCreationDialog.vue b/core/frontend/src/components/video-manager/VideoStreamCreationDialog.vue index d5a382d466..ac034b4450 100644 --- a/core/frontend/src/components/video-manager/VideoStreamCreationDialog.vue +++ b/core/frontend/src/components/video-manager/VideoStreamCreationDialog.vue @@ -116,10 +116,22 @@ v-model="is_thermal" label="Thermal camera" /> + + + @@ -206,7 +218,10 @@ export default Vue.extend({ interval: undefined, endpoints: [''], thermal: false, + disable_lazy: false, disable_mavlink: false, + disable_thumbnails: false, + disable_zenoh: false, } }, }, @@ -224,7 +239,10 @@ export default Vue.extend({ selected_interval: this.stream.interval, stream_endpoints: this.stream.endpoints, is_thermal: this.stream.thermal, + is_disable_lazy: this.stream.disable_lazy, is_disable_mavlink: this.stream.disable_mavlink, + is_disable_thumbnails: this.stream.disable_thumbnails, + is_disable_zenoh: this.stream.disable_zenoh, settings, } }, @@ -259,7 +277,10 @@ export default Vue.extend({ }, extended_configuration: { thermal: this.is_thermal, + disable_lazy: this.is_disable_lazy, disable_mavlink: this.is_disable_mavlink, + disable_thumbnails: this.is_disable_thumbnails, + disable_zenoh: this.is_disable_zenoh, }, }, } @@ -311,10 +332,36 @@ export default Vue.extend({ return this.stream_name.replace(/[^a-z0-9]/gi, '_').toLowerCase() }, }, + watch: { + show(newVal: boolean) { + if (newVal) { + this.resetFormFromStream() + } + }, + }, mounted() { this.set_default_configurations() }, methods: { + resetFormFromStream(): void { + const format_match = this.device.formats + .find((format) => format.encode === this.stream.encode) + const size_match = format_match?.sizes + .find((size) => size.width === this.stream.dimensions?.width + && size.height === this.stream.dimensions?.height) + + this.stream_name = this.stream.name + this.selected_encode = this.stream.encode + this.selected_size = size_match || null + this.selected_interval = this.stream.interval + this.stream_endpoints = this.stream.endpoints + this.is_thermal = this.stream.thermal + this.is_disable_lazy = this.stream.disable_lazy + this.is_disable_mavlink = this.stream.disable_mavlink + this.is_disable_thumbnails = this.stream.disable_thumbnails + this.is_disable_zenoh = this.stream.disable_zenoh + this.set_default_configurations() + }, validate_required_field(input: string | null): (true | string) { if (!this.format_required) { return true