From 96c2d52ee05be0dd9c906c5523f64bfb904236b3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 27 May 2026 20:28:51 +0000 Subject: [PATCH] perf(frontend): fix resource leaks and disable deep reactivity on media index - useMediaWebSocket: shallowRef mediaIndex/tasks/loading/error/connected to eliminate Proxy overhead on the entire library data structure. - useMediaWebSocket: replace per-task setTimeout leak with single 3s sweep interval for completed task cleanup. - App.vue: remove {deep:true} watcher on mediaIndex; shallow ref change is sufficient to trigger search worker sync. - useGamepadNavigation: rAF only when gamepads are connected; idle fallback to 500ms setTimeout to stop permanent 60fps CPU drain. - useKeyboardNavigation: deduplicate synced scroll rAF requests to prevent overlapping animation frames. - MediaDetail/SeriesFullView/CollageHero: pause, clear src, and load() video elements on unmount and before ref replacement to release decoder/memory resources. - MediaDetail/SeriesFullView: clear all volume fade intervals on unmount to stop interval timer leaks. --- frontend/src/App.vue | 2 +- frontend/src/components/CollageHero.vue | 11 ++++ frontend/src/components/MediaDetail.vue | 35 +++++++++++- frontend/src/components/SeriesFullView.vue | 21 +++++++ .../src/composables/useGamepadNavigation.ts | 40 ++++++++++++-- .../src/composables/useKeyboardNavigation.ts | 13 +++-- frontend/src/composables/useMediaWebSocket.ts | 55 +++++++++++++------ 7 files changed, 149 insertions(+), 28 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cc07fd0..3d2d2c4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1020,7 +1020,7 @@ watch(mediaIndex, () => { syncWorkerIndex() runSearch() } -}, { deep: true }) +}) // Sort by newest timestamp (descending) function sortByNewest(items: MediaItem[]): MediaItem[] { diff --git a/frontend/src/components/CollageHero.vue b/frontend/src/components/CollageHero.vue index 7babce1..c360173 100644 --- a/frontend/src/components/CollageHero.vue +++ b/frontend/src/components/CollageHero.vue @@ -181,9 +181,20 @@ onMounted(() => { }) }) +function cleanupHeroVideos() { + // Find all video elements inside the hero and explicitly release them + const videos = document.querySelectorAll(".collage-hero video") + videos.forEach((video) => { + video.pause() + video.src = "" + video.load() + }) +} + onUnmounted(() => { window.removeEventListener("resize", updateVisibility) document.removeEventListener("focusin", handleDocumentFocusIn) + cleanupHeroVideos() }) function clearHeroFocus() { diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index e954e60..00c4f20 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -300,7 +300,18 @@ function registerMovieOutOfBoundsShortcut() { }) } +function cleanupVideo(video: HTMLVideoElement | null | undefined) { + if (!video) return + video.pause() + video.src = "" + video.load() +} + function setVideoRef(el: HTMLVideoElement | null, index: number) { + const old = videoRefs.value[index] + if (old && old !== el) { + cleanupVideo(old) + } videoRefs.value[index] = el } @@ -450,7 +461,19 @@ const collageSlots = computed(() => { watch( collageSlots, - async (slots) => { + async (slots, oldSlots) => { + // Pause and unload videos that are no longer referenced before reassigning refs + if (oldSlots) { + for (let i = 0; i < oldSlots.length; i++) { + const oldPaths = oldSlots[i]?.sourcePaths ?? [] + const newPaths = slots[i]?.sourcePaths ?? [] + const changed = + oldPaths.length !== newPaths.length || oldPaths.some((p, idx) => p !== newPaths[idx]) + if (changed) { + cleanupVideo(videoRefs.value[i]) + } + } + } videoRefs.value = Array.from( { length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null, @@ -722,6 +745,16 @@ onUnmounted(() => { disposeOutOfBoundsHandler?.() disposeOutOfBoundsHandler = null lastReleaseShortcutRow = null + // Clear all volume fade intervals + for (const interval of volumeFadeIntervals.values()) { + clearInterval(interval) + } + volumeFadeIntervals.clear() + // Pause and unload all video elements + for (const video of videoRefs.value) { + cleanupVideo(video) + } + videoRefs.value = [] }) diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 9cccd2e..3dc9fc9 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -468,6 +468,13 @@ const videoRefs = ref>(new Map()) let videoIndex = 0 const safariAutoplay = isSafariBrowser() +function cleanupVideo(video: HTMLVideoElement | null | undefined) { + if (!video) return + video.pause() + video.src = "" + video.load() +} + // Set video ref with staggered playback function setVideoRef(el: HTMLVideoElement | null, key: string) { if (el) { @@ -484,6 +491,10 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) { safariAutoplay ? 0 : index * 200, ) } else { + const old = videoRefs.value.get(key) + if (old) { + cleanupVideo(old) + } videoRefs.value.delete(key) } } @@ -649,6 +660,16 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) + // Clear all volume fade intervals + for (const interval of volumeFadeIntervals.values()) { + clearInterval(interval) + } + volumeFadeIntervals.clear() + // Pause and unload all video elements + for (const video of videoRefs.value.values()) { + cleanupVideo(video) + } + videoRefs.value.clear() }) diff --git a/frontend/src/composables/useGamepadNavigation.ts b/frontend/src/composables/useGamepadNavigation.ts index 247741c..59be311 100644 --- a/frontend/src/composables/useGamepadNavigation.ts +++ b/frontend/src/composables/useGamepadNavigation.ts @@ -8,6 +8,7 @@ const DIGITAL_REPEAT_MIN_MS = 16 const DIGITAL_ACCEL_RAMP_MS = 3500 const ANALOG_REPEAT_MAX_MS = 180 const ANALOG_REPEAT_MIN_MS = 16 +const IDLE_POLL_MS = 500 const KEY_BY_ACTION: Partial> = { up: "ArrowUp", @@ -47,6 +48,7 @@ let digitalLastRepeatAt = 0 let digitalRepeatCount = 0 let gamepadFrameId: number | null = null +let idleTimerId: number | null = null let gamepadInstalled = false function dispatchKey(key: string) { @@ -152,6 +154,27 @@ function resetPressedState() { digitalRepeatCount = 0 } +function stopPolling() { + if (gamepadFrameId !== null) { + window.cancelAnimationFrame(gamepadFrameId) + gamepadFrameId = null + } + if (idleTimerId !== null) { + window.clearTimeout(idleTimerId) + idleTimerId = null + } +} + +function scheduleIdlePoll() { + if (idleTimerId !== null) return + idleTimerId = window.setTimeout(() => { + idleTimerId = null + if (gamepadInstalled) { + pollGamepad() + } + }, IDLE_POLL_MS) +} + function pollGamepad() { const gamepads = navigator.getGamepads?.() ?? [] const now = performance.now() @@ -256,10 +279,20 @@ function pollGamepad() { applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now) applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now) applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now) + + // Keep using rAF while gamepads are active for responsive input + gamepadFrameId = window.requestAnimationFrame(pollGamepad) } else { resetPressedState() + // No gamepads connected — drop to slow polling to save CPU + scheduleIdlePoll() } +} +function handleGamepadConnected() { + if (!gamepadInstalled) return + // A gamepad was plugged in; make sure we're polling + stopPolling() gamepadFrameId = window.requestAnimationFrame(pollGamepad) } @@ -267,14 +300,13 @@ export function installGamepadNavigation() { if (gamepadInstalled) return gamepadInstalled = true gamepadFrameId = window.requestAnimationFrame(pollGamepad) + window.addEventListener("gamepadconnected", handleGamepadConnected) } export function uninstallGamepadNavigation() { if (!gamepadInstalled) return gamepadInstalled = false - if (gamepadFrameId !== null) { - window.cancelAnimationFrame(gamepadFrameId) - gamepadFrameId = null - } + stopPolling() resetPressedState() + window.removeEventListener("gamepadconnected", handleGamepadConnected) } diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts index 19ffffb..c51964a 100644 --- a/frontend/src/composables/useKeyboardNavigation.ts +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -177,9 +177,12 @@ function resetSyncedRows(immediate: boolean = false) { return } - if (syncedRowsFrame === null) { - syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows) - } + startSyncedRowAnimation() +} + +function startSyncedRowAnimation() { + if (syncedRowsFrame !== null) return + syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows) } function stopSyncedRowAnimation() { @@ -281,9 +284,7 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null return } - if (syncedRowsFrame === null) { - syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows) - } + startSyncedRowAnimation() } function getLocalSyncedRowCol( diff --git a/frontend/src/composables/useMediaWebSocket.ts b/frontend/src/composables/useMediaWebSocket.ts index cb3543d..c623482 100644 --- a/frontend/src/composables/useMediaWebSocket.ts +++ b/frontend/src/composables/useMediaWebSocket.ts @@ -1,4 +1,4 @@ -import { ref, readonly, onUnmounted } from "vue" +import { shallowRef, readonly, onUnmounted } from "vue" import type { Movie, Series, @@ -30,15 +30,41 @@ interface RootState { * - "task" → background task progress */ export function useMediaWebSocket() { - const mediaIndex = ref(null) - const loading = ref(true) - const error = ref(null) - const connected = ref(false) - const tasks = ref>(new Map()) + const mediaIndex = shallowRef(null) + const loading = shallowRef(true) + const error = shallowRef(null) + const connected = shallowRef(false) + const tasks = shallowRef>(new Map()) - const roots = ref>(new Map()) + const roots = shallowRef>(new Map()) let disposed = false + // Single periodic sweep for completed tasks instead of one timeout per task + const completedTaskIds = new Set() + let taskSweepTimer: ReturnType | null = null + function startTaskSweep() { + if (taskSweepTimer !== null) return + taskSweepTimer = setInterval(() => { + if (completedTaskIds.size === 0) return + const next = new Map(tasks.value) + let changed = false + for (const id of completedTaskIds) { + if (next.delete(id)) changed = true + } + completedTaskIds.clear() + if (changed) { + tasks.value = next + } + }, 3000) + } + function stopTaskSweep() { + if (taskSweepTimer !== null) { + clearInterval(taskSweepTimer) + taskSweepTimer = null + } + } + onUnmounted(stopTaskSweep) + function getContentHash(itemId: string): string { return itemId.split(":").pop() || itemId } @@ -276,16 +302,12 @@ export function useMediaWebSocket() { } case "task": { const info = msg.data - if (info.status === "completed" || info.status === "cancelled" || info.status === "error") { - tasks.value.set(info.id, info) - setTimeout(() => { - tasks.value.delete(info.id) - tasks.value = new Map(tasks.value) - }, 3000) - } else { - tasks.value.set(info.id, info) - } + tasks.value.set(info.id, info) tasks.value = new Map(tasks.value) + if (info.status === "completed" || info.status === "cancelled" || info.status === "error") { + completedTaskIds.add(info.id) + startTaskSweep() + } break } } @@ -410,6 +432,7 @@ export function useMediaWebSocket() { function disconnect() { disposed = true + stopTaskSweep() for (const state of roots.value.values()) { if (state.reconnectTimer) { clearTimeout(state.reconnectTimer)