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.
This commit is contained in:
2026-05-27 20:28:51 +00:00
parent 79d3f40162
commit 96c2d52ee0
7 changed files with 149 additions and 28 deletions
+1 -1
View File
@@ -1020,7 +1020,7 @@ watch(mediaIndex, () => {
syncWorkerIndex()
runSearch()
}
}, { deep: true })
})
// Sort by newest timestamp (descending)
function sortByNewest(items: MediaItem[]): MediaItem[] {
+11
View File
@@ -181,9 +181,20 @@ onMounted(() => {
})
})
function cleanupHeroVideos() {
// Find all video elements inside the hero and explicitly release them
const videos = document.querySelectorAll<HTMLVideoElement>(".collage-hero video")
videos.forEach((video) => {
video.pause()
video.src = ""
video.load()
})
}
onUnmounted(() => {
window.removeEventListener("resize", updateVisibility)
document.removeEventListener("focusin", handleDocumentFocusIn)
cleanupHeroVideos()
})
function clearHeroFocus() {
+34 -1
View File
@@ -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 = []
})
</script>
@@ -468,6 +468,13 @@ const videoRefs = ref<Map<string, HTMLVideoElement>>(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()
})
</script>
@@ -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<Record<GamepadAction, string>> = {
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)
}
@@ -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(
+39 -16
View File
@@ -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<MediaIndex | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const connected = ref(false)
const tasks = ref<Map<string, TaskInfo>>(new Map())
const mediaIndex = shallowRef<MediaIndex | null>(null)
const loading = shallowRef(true)
const error = shallowRef<string | null>(null)
const connected = shallowRef(false)
const tasks = shallowRef<Map<string, TaskInfo>>(new Map())
const roots = ref<Map<string, RootState>>(new Map())
const roots = shallowRef<Map<string, RootState>>(new Map())
let disposed = false
// Single periodic sweep for completed tasks instead of one timeout per task
const completedTaskIds = new Set<string>()
let taskSweepTimer: ReturnType<typeof setInterval> | 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)