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:
@@ -1020,7 +1020,7 @@ watch(mediaIndex, () => {
|
||||
syncWorkerIndex()
|
||||
runSearch()
|
||||
}
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
// Sort by newest timestamp (descending)
|
||||
function sortByNewest(items: MediaItem[]): MediaItem[] {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user