diff --git a/src/lib/offline/offlineManager.svelte.ts b/src/lib/offline/offlineManager.svelte.ts index 14f5a47..8b02115 100644 --- a/src/lib/offline/offlineManager.svelte.ts +++ b/src/lib/offline/offlineManager.svelte.ts @@ -146,7 +146,7 @@ class OfflineManager { } void this.loadSyncQueue(); } else if (this.reconnectTimer === null) { - this.reconnectTimer = setInterval(() => void this.tryReconnect(), 15_000); + this.reconnectTimer = setInterval(() => void this.tryReconnect(), 30_000); } } diff --git a/src/lib/player.svelte.ts b/src/lib/player.svelte.ts index 749ee1d..18634b4 100644 --- a/src/lib/player.svelte.ts +++ b/src/lib/player.svelte.ts @@ -18,6 +18,30 @@ for (const el of [audio, audioNext]) { el.crossOrigin = "anonymous"; el.preload = "auto"; } + +type LoadedSource = { + song: Song; + queueKey?: number; + transition?: string; + transcoded: boolean; + offset: number; + seek?: number; +}; + +type AudioSlot = { + el: HTMLAudioElement; + generation: number; + source: LoadedSource | null; + pendingTransition?: string; + failedTransition?: string; +}; + +const audioSlots: [AudioSlot, AudioSlot] = [ + { el: audio, generation: 0, source: null }, + { el: audioNext, generation: 0, source: null }, +]; +const slotFor = (el: HTMLAudioElement) => + el === audio ? audioSlots[0] : audioSlots[1]; const faviconUrl = `${import.meta.env.BASE_URL}favicon.svg`; const setMediaAction = ( @@ -33,56 +57,73 @@ const bindEvents = (el: HTMLAudioElement) => { const isCurrent = () => el === player.audio; el.onpause = () => { - if (!isCurrent()) return; + if (!isCurrent() || !el.paused || player.loading) return; player.paused = true; updateMediaPosition(); }; el.onplay = () => { - if (!isCurrent()) return; + if (!isCurrent() || el.paused) return; player.paused = false; updateMediaPosition(); }; el.onended = () => { - if (isCurrent() && !player.loading) { - advance(); + if (!el.ended) return; + if (!isCurrent()) return clearSlot(slotFor(el)); + if (pendingHandoff) { + cancelHandoff(); + if (!player.loading) advance(); + return; } + if (!player.loading) advance(); }; el.ontimeupdate = () => { if (!isCurrent()) return; if (player.loading) return; - if (!pendingOffsetSeek && !pendingDirectSeek) - player.time = streamOffset + el.currentTime; - if (player.duration <= 0) return; - const remaining = player.duration - player.time; - if (remaining <= HANDOFF_PRELOAD) void preloadNext(); + const slot = slotFor(el); + if (!seekTimeout && !el.seeking) + player.time = (slot.source?.offset || 0) + el.currentTime; + if (!player.track || isRadio(player.track)) return; + if (seekTimeout || el.seeking) return; + const remaining = mediaRemaining(slot); + if (remaining <= 5) void preloadNext(); if (remaining <= HANDOFF_LOOKAHEAD && !nearEndTimer) - nearEndTimer = setInterval(checkHandoff, HANDOFF_POLL_MS); + nearEndTimer = setInterval(checkHandoff, 25); }; el.onseeked = () => { - if (!isCurrent()) return; - pendingDirectSeek = false; + if (!isCurrent() || el.seeking) return; updateMediaPosition(); }; el.onloadedmetadata = () => { if (!isCurrent()) return; - pendingOffsetSeek = false; + const slot = slotFor(el); + if (!slot.source || el.readyState < HTMLMediaElement.HAVE_METADATA) return; if (player.track && !isRadio(player.track)) { - player.duration = - settings.transcoding && player.track.duration - ? player.track.duration - : Number.isFinite(el.duration) - ? el.duration + streamOffset - : player.track?.duration || 0; - if (pendingTime > 0) el.currentTime = player.time = pendingTime; - pendingTime = 0; + player.duration = sourceDuration(slot); + if (slot.source.seek) { + el.currentTime = player.time = slot.source.seek; + slot.source.seek = undefined; + } } updateMediaPosition(); }; el.oncanplay = () => { + if (!slotFor(el).source) return; + if (el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) return; if (isCurrent()) finishLoading(); }; el.onerror = () => { - if (isCurrent()) finishLoading(); + if (!el.error) return; + const slot = slotFor(el); + if (isCurrent()) { + if (!slot.source) return; + finishLoading(); + stopNearEndPolling(); + player.paused = true; + } else if (slot.source?.transition) { + recordPreloadFailure(slot, slot.source.transition); + } else if (slot.source) { + clearSlot(slot); + } }; el.onplaying = () => { if (!isCurrent()) return; @@ -111,24 +152,25 @@ export const formatTime = (seconds = 0) => { .padStart(2, "0")}`; }; -let pendingTime = 0; -let streamOffset = 0; let seekTimeout: ReturnType | undefined; -let pendingOffsetSeek = false; -let pendingDirectSeek = false; +let playbackOccurrence = $state(0); const clearPendingSeek = () => { clearTimeout(seekTimeout); seekTimeout = undefined; - pendingOffsetSeek = false; - pendingDirectSeek = false; }; const finishLoading = () => (player.loading = false); +const isAbortError = (err: unknown) => + err instanceof DOMException && err.name === "AbortError"; const resumePlayback = () => { - void player.audio.play().catch((err) => { - console.warn("failed to resume playback:", err); + const el = player.audio; + const generation = slotFor(el).generation; + void el.play().catch((err) => { + if (player.audio === el && slotFor(el).generation === generation) + player.paused = true; + if (!isAbortError(err)) console.warn("failed to resume playback:", err); }); }; @@ -147,18 +189,14 @@ const playbackUrl = (id: string, timeOffset = 0) => ? api.download(id) : streamUrl(id, timeOffset); -const setAudioSrc = (el: HTMLAudioElement, url: string, local: boolean) => { - el.src = url; - if (local) el.removeAttribute("crossorigin"); - else el.crossOrigin = "anonymous"; -}; - -const HANDOFF_DELAY = 0.1; +const HANDOFF_LEAD = 0.1; const HANDOFF_LOOKAHEAD = 2; -const HANDOFF_PRELOAD = 5; -const HANDOFF_POLL_MS = 25; let nearEndTimer: ReturnType | undefined; +let pendingHandoff: { + slot: AudioSlot; + generation: number; +} | null = null; const stopNearEndPolling = () => { clearInterval(nearEndTimer); @@ -170,83 +208,191 @@ const idleAudio = () => (player.audio === audio ? audioNext : audio); const isFinishing = (el: HTMLAudioElement) => !el.paused && Number.isFinite(el.duration) && - el.duration - el.currentTime <= HANDOFF_DELAY; + el.duration - el.currentTime <= HANDOFF_LEAD; + +const applyVolume = (slot: AudioSlot) => { + const replayGain = slot.source?.song.replayGain; + const gain = + settings.replayGainMode === "album" + ? replayGain?.albumGain + : settings.replayGainMode === "track" + ? replayGain?.trackGain + : undefined; + slot.el.volume = Math.min( + 1, + Math.max(0, player.vol * (gain !== undefined ? 10 ** (gain / 20) : 1)), + ); +}; -const clearPreload = (el = idleAudio()) => { - el.dataset.trackId = ""; - el.pause(); - el.removeAttribute("src"); - el.load(); +const clearSlot = (slot: AudioSlot) => { + slot.generation++; + slot.source = null; + slot.pendingTransition = undefined; + slot.failedTransition = undefined; + slot.el.pause(); + slot.el.removeAttribute("src"); + slot.el.load(); +}; + +const resetIdlePlayback = (slot: AudioSlot, generation: number) => { + if (slot.el === player.audio || slot.generation !== generation) return; + slot.el.pause(); + if (Number.isFinite(slot.el.duration)) slot.el.currentTime = 0; +}; + +const cancelHandoff = () => { + const handoff = pendingHandoff; + pendingHandoff = null; + if (!handoff) return; + resetIdlePlayback(handoff.slot, handoff.generation); +}; + +const assignSource = ( + slot: AudioSlot, + source: LoadedSource, + url: string, + local = false, +) => { + slot.pendingTransition = undefined; + slot.source = source; + if (local) slot.el.removeAttribute("crossorigin"); + else slot.el.crossOrigin = "anonymous"; + slot.el.src = url; + applyVolume(slot); +}; + +const recordPreloadFailure = (slot: AudioSlot, transition: string) => { + clearSlot(slot); + slot.failedTransition = transition; +}; + +const sourceDuration = (slot: AudioSlot) => { + const source = slot.source; + if (!source) return 0; + if (source.transcoded && source.song.duration) return source.song.duration; + return Number.isFinite(slot.el.duration) && slot.el.duration > 0 + ? slot.el.duration + source.offset + : source.song.duration || 0; +}; + +const mediaRemaining = (slot: AudioSlot) => { + if (Number.isFinite(slot.el.duration) && slot.el.duration > 0) + return slot.el.duration - slot.el.currentTime; + const duration = sourceDuration(slot); + return duration > 0 + ? duration - (slot.source?.offset || 0) - slot.el.currentTime + : Number.POSITIVE_INFINITY; }; const preloadNext = async () => { + const fromKey = queue.keys[queue.pos]; + const index = nextTarget(); + const targetKey = queue.keys[index]; + const nextTrack = queue.tracks[index]; + const slot = slotFor(idleAudio()); + const occurrence = playbackOccurrence; + if ( + !player.track || + isRadio(player.track) || + !nextTrack || + isRadio(nextTrack) || + fromKey === undefined || + targetKey === undefined || + isFinishing(slot.el) + ) + return; + if (slot.source?.queueKey === targetKey) { + if (pendingHandoff?.slot === slot || !slot.el.paused) return; + if (slot.el.currentTime) slot.el.currentTime = 0; + applyVolume(slot); + return; + } + const transition = `${fromKey}:${targetKey}:${occurrence}`; + if (slot.pendingTransition === transition) return; + if (slot.failedTransition === transition) return; + + clearSlot(slot); + const generation = slot.generation; + slot.pendingTransition = transition; try { - const track = player.track; - const nextTrack = queue.tracks[nextTarget()]; - const el = idleAudio(); - if (!track || isRadio(track) || !nextTrack || isRadio(nextTrack)) return; - if (nextTrack.id === track.id) return; - if (el.dataset.trackId === nextTrack.id) return; - if (isFinishing(el)) return; - clearPreload(el); - el.dataset.trackId = nextTrack.id; const localAudioUrl = await offlineManager.getAudioUrl(nextTrack); - if (player.track !== track || queue.tracks[nextTarget()] !== nextTrack) { - if (el !== player.audio) clearPreload(el); + if ( + slot.generation !== generation || + slot.el === player.audio || + playbackOccurrence !== occurrence || + queue.keys[queue.pos] !== fromKey || + queue.keys[nextTarget()] !== targetKey + ) { + if (slot.generation === generation) clearSlot(slot); return; } - - setAudioSrc( - el, + assignSource( + slot, + { + song: nextTrack, + queueKey: targetKey, + transition, + transcoded: settings.transcoding && !localAudioUrl, + offset: 0, + }, localAudioUrl || playbackUrl(nextTrack.id), !!localAudioUrl, ); - el.load(); + slot.el.load(); } catch (err) { + if (slot.generation === generation) recordPreloadFailure(slot, transition); console.warn("failed to preload next track:", err); } }; const checkHandoff = () => { + const remaining = mediaRemaining(slotFor(player.audio)); if ( !player.track || isRadio(player.track) || player.paused || - player.duration <= 0 || - player.duration - player.time > HANDOFF_LOOKAHEAD + remaining > HANDOFF_LOOKAHEAD ) { stopNearEndPolling(); return; } - const nextTrack = queue.tracks[nextTarget()]; - const el = idleAudio(); - if (!nextTrack || isRadio(nextTrack) || el.dataset.trackId !== nextTrack.id) { + const index = nextTarget(); + const nextTrack = queue.tracks[index]; + const slot = slotFor(idleAudio()); + if ( + !nextTrack || + isRadio(nextTrack) || + slot.source?.queueKey !== queue.keys[index] + ) { stopNearEndPolling(); return; } - if (el.readyState < 3) return; - const currentTime = streamOffset + player.audio.currentTime; - if (currentTime + HANDOFF_DELAY < player.duration) return; + if (slot.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) return; + if (remaining > HANDOFF_LEAD) return; handoff(); }; const promote = (el: HTMLAudioElement, song: Song, index: number) => { const previous = player.audio; + const slot = slotFor(el); + if (slot.source) { + slot.source.song = song; + slot.source.transition = undefined; + applyVolume(slot); + } clearPendingSeek(); if (settings.scrobbling && player.track) { api.scrobble(player.track.id).catch(() => {}); } player.audio = el; player.track = song; - player.time = 0; - player.duration = - song.duration || (Number.isFinite(el.duration) ? el.duration : 0); + playbackOccurrence++; + player.time = el.currentTime; + player.duration = sourceDuration(slot); player.loading = false; - streamOffset = 0; - pendingTime = 0; queue.pos = index; if (!isFinishing(previous)) { - clearPreload(previous); + clearSlot(slotFor(previous)); } if (settings.visualizer) visualizer.refresh(); player.paused = false; @@ -255,32 +401,61 @@ const promote = (el: HTMLAudioElement, song: Song, index: number) => { const handoff = () => { const cur = player.audio; - const pos = queue.pos; + const fromKey = queue.keys[queue.pos]; const index = nextTarget(); const song = queue.tracks[index]; - const el = idleAudio(); - const remaining = () => - Number.isFinite(cur.duration) ? cur.duration - cur.currentTime : 0; - const remainingAtHandoff = remaining(); + const targetKey = queue.keys[index]; + const slot = slotFor(idleAudio()); + if (pendingHandoff) return; + if ( + !song || + isRadio(song) || + fromKey === undefined || + targetKey === undefined || + slot.source?.queueKey !== targetKey || + slot.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA + ) + return; + const generation = slot.generation; + const startedAt = cur.currentTime; + const handoff = { slot, generation }; + pendingHandoff = handoff; stopNearEndPolling(); - void el - .play() - .then(() => { - if ( - song && - player.audio === cur && - queue.pos === pos && - queue.tracks[nextTarget()] === song && - el.dataset.trackId === song.id && - !player.paused && - remaining() <= remainingAtHandoff + HANDOFF_DELAY - ) { - promote(el, song, index); - } else if (player.audio !== el) { - el.pause(); - } - }) - .catch(() => {}); + void (async () => { + let started = false; + try { + await slot.el.play(); + started = true; + } catch (err) { + if (pendingHandoff === handoff && !isAbortError(err)) + console.warn("failed to hand off playback:", err); + } + if (pendingHandoff !== handoff) { + resetIdlePlayback(slot, generation); + return; + } + pendingHandoff = null; + + const liveIndex = nextTarget(); + const liveSong = queue.tracks[liveIndex]; + if ( + started && + liveSong && + !isRadio(liveSong) && + slot.generation === generation && + player.audio === cur && + queue.keys[queue.pos] === fromKey && + queue.keys[liveIndex] === targetKey && + !player.paused && + cur.currentTime + HANDOFF_LEAD >= startedAt + ) { + promote(slot.el, liveSong, liveIndex); + return; + } + + resetIdlePlayback(slot, generation); + if (player.audio === cur && !player.paused && cur.ended) advance(); + })(); }; const advance = () => { @@ -382,47 +557,65 @@ export const visualizer = $state({ }); export const load = async (song: Song, time = 0, autoplay = false) => { + const slot = slotFor(player.audio); + const queueKey = + queue.tracks[queue.pos]?.id === song.id ? queue.keys[queue.pos] : undefined; + player.loading = true; clearPendingSeek(); stopNearEndPolling(); - idleAudio().pause(); + cancelHandoff(); + clearSlot(slotFor(idleAudio())); + clearSlot(slot); + const slotGeneration = slot.generation; player.track = song; player.time = time; - player.loading = true; + player.paused = !autoplay; if (isRadio(song)) { - streamOffset = 0; - pendingTime = 0; player.duration = 0; - player.audio.src = song._radioStreamUrl ?? ""; - player.audio.crossOrigin = "anonymous"; + assignSource( + slot, + { + song, + queueKey, + transcoded: false, + offset: 0, + }, + song._radioStreamUrl ?? "", + ); } else { - streamOffset = settings.transcoding ? Math.floor(time) : 0; player.duration = song.duration || 0; - pendingTime = streamOffset ? 0 : time; - const localAudioUrl = await offlineManager.getAudioUrl(song); - if (player.track !== song) return; - setAudioSrc( - player.audio, - localAudioUrl || playbackUrl(song.id, streamOffset), + if (slotGeneration !== slot.generation || slot.el !== player.audio) return; + const offset = + localAudioUrl || !settings.transcoding ? 0 : Math.floor(time); + assignSource( + slot, + { + song, + queueKey, + transcoded: settings.transcoding && !localAudioUrl, + offset, + seek: offset ? undefined : time || undefined, + }, + localAudioUrl || playbackUrl(song.id, offset), !!localAudioUrl, ); - if (localAudioUrl) { - streamOffset = 0; - pendingTime = time; - } } - player.audio.load(); - if (autoplay) { - resumePlayback(); - } - player.paused = !autoplay; + slot.el.load(); + if (!player.paused) resumePlayback(); }; export const play = (song: Song) => { if (player.track?.id === song.id) { - if (isRadio(song)) return load(song, 0, true); + if (isRadio(song) || player.loading) return load(song, 0, true); + playbackOccurrence++; + if ( + player.audio.error || + (settings.transcoding && !offlineAudios.has(song.id)) + ) + return load(song, 0, true); seek(0); resumePlayback(); } else load(song, 0, true); @@ -432,8 +625,12 @@ export const toggle = () => { if (!player.track) return queue.tracks.length > 0 && goto(queue.pos === -1 ? 0 : queue.pos); if (player.paused) { - resumePlayback(); + if (player.loading || player.audio.error) + load(player.track, player.time, true); + else resumePlayback(); } else { + cancelHandoff(); + player.paused = true; player.audio.pause(); } }; @@ -441,10 +638,8 @@ export const toggle = () => { export const stop = () => { clearPendingSeek(); stopNearEndPolling(); - streamOffset = 0; - player.audio.pause(); - player.audio.src = ""; - clearPreload(); + cancelHandoff(); + for (const slot of audioSlots) clearSlot(slot); player.track = null; player.paused = true; player.time = 0; @@ -456,10 +651,13 @@ export const stop = () => { export const seek = (time: number) => { if (player.track && isRadio(player.track)) return; + cancelHandoff(); + stopNearEndPolling(); + if (player.loading && player.track) + return void load(player.track, time, !player.paused); const isLocal = player.track && offlineAudios.has(player.track.id); if (settings.transcoding && !isLocal && player.track) { const track = player.track; - pendingOffsetSeek = true; player.time = time; clearTimeout(seekTimeout); seekTimeout = setTimeout(() => { @@ -470,8 +668,6 @@ export const seek = (time: number) => { return; } clearPendingSeek(); - streamOffset = 0; - pendingDirectSeek = true; player.audio.currentTime = player.time = time; }; @@ -496,7 +692,7 @@ export const setupMediaSession = () => { }; $effect.root(() => { - let lastNowPlayingId = ""; + let lastNowPlayingKey: string | undefined; let lastStreamSettings = ""; let streamSettingsTimeout: ReturnType | undefined; @@ -511,18 +707,7 @@ $effect.root(() => { }); $effect(() => { - const replayGain = player.track?.replayGain; - let gain: number | undefined; - if (settings.replayGainMode === "album") { - gain = replayGain?.albumGain; - } else if (settings.replayGainMode === "track") { - gain = replayGain?.trackGain; - } - const targetVolume = Math.min( - 1, - Math.max(0, player.vol * (gain !== undefined ? 10 ** (gain / 20) : 1)), - ); - audio.volume = audioNext.volume = targetVolume; + for (const slot of audioSlots) applyVolume(slot); }); $effect(() => { @@ -531,10 +716,7 @@ $effect.root(() => { clearTimeout(streamSettingsTimeout); streamSettingsTimeout = setTimeout(() => { streamSettingsTimeout = undefined; - if (player.track) { - clearPreload(); - load(player.track, player.time, !player.paused); - } + if (player.track) load(player.track, player.time, !player.paused); }, 1000); } lastStreamSettings = streamSettings; @@ -542,14 +724,17 @@ $effect.root(() => { $effect(() => { const { track } = player; + const playbackKey = track + ? `${queue.keys[queue.pos] ?? track.id}:${playbackOccurrence}` + : undefined; if ( track && !isRadio(track) && settings.scrobbling && !player.paused && - track.id !== lastNowPlayingId + playbackKey !== lastNowPlayingKey ) { - lastNowPlayingId = track.id; + lastNowPlayingKey = playbackKey; api.scrobble(track.id, false).catch(() => {}); } }); @@ -579,7 +764,7 @@ $effect.root(() => { }); } } else { - lastNowPlayingId = ""; + lastNowPlayingKey = undefined; document.title = "tinysub"; if (link) { link.href = faviconUrl; diff --git a/src/lib/queue.svelte.ts b/src/lib/queue.svelte.ts index 0b012f9..84a8b5a 100644 --- a/src/lib/queue.svelte.ts +++ b/src/lib/queue.svelte.ts @@ -715,8 +715,81 @@ export const syncQueue = async () => { record(); -$effect.root(() => { - let timer: ReturnType; +const destroyQueueEffects = $effect.root(() => { + type QueueSnapshot = { + ids: string[]; + currentIndex?: number; + position?: number; + signature: string; + }; + + let saveTimer: ReturnType | undefined; + let activeSave = ""; + let destroyed = false; + let pendingSave: QueueSnapshot | undefined; + + const writeQueue = async (snapshot: QueueSnapshot) => { + activeSave = snapshot.signature; + try { + await api.savePlayQueue( + snapshot.ids, + snapshot.currentIndex, + snapshot.position, + ); + } catch (err) { + console.warn("failed to save play queue:", err); + } + activeSave = ""; + const next = pendingSave; + pendingSave = undefined; + if ( + !destroyed && + next && + !ui.busy && + auth.loggedIn && + !offlineManager.isOfflineMode + ) + void writeQueue(next); + }; + + const saveQueue = () => { + clearTimeout(saveTimer); + if (ui.busy || !auth.loggedIn || offlineManager.isOfflineMode) return; + + const current = queue.tracks[queue.pos]; + const ids: string[] = []; + let precedingSaveable = 0; + for (let index = 0; index < queue.tracks.length; index++) { + const track = queue.tracks[index]; + if (isRadio(track)) continue; + ids.push(track.id); + if (index < queue.pos) precedingSaveable++; + } + const currentIndex = + queue.pos === -1 || !ids.length + ? undefined + : current && !isRadio(current) + ? precedingSaveable + : Math.max(precedingSaveable - 1, 0); + const position = + current && !isRadio(current) ? Math.floor(player.time * 1000) : undefined; + const signature = JSON.stringify([ids, currentIndex, position]); + const snapshot = { ids, currentIndex, position, signature }; + if (!activeSave) { + return void writeQueue(snapshot); + } + if (signature === activeSave) { + pendingSave = undefined; + return; + } + if (pendingSave?.signature !== signature) pendingSave = snapshot; + }; + + const scheduleSave = () => { + clearTimeout(saveTimer); + saveTimer = setTimeout(saveQueue, 1000); + }; + $effect(() => { queue.pos; @@ -729,31 +802,28 @@ $effect.root(() => { if (!player.paused) play(track); else load(track, 0); } + }); - if (ui.busy || !auth.loggedIn) return; - clearTimeout(timer); - timer = setTimeout(() => { - const saveable = queue.tracks.filter((t) => !isRadio(t)); - if (!saveable.length) return api.savePlayQueue([]); - const current = queue.tracks[queue.pos]; - const savePos = - current && !isRadio(current) - ? saveable.indexOf(current) - : Math.max( - saveable.indexOf( - [...queue.tracks.slice(0, Math.max(queue.pos, 0))] - .reverse() - .find((t) => !isRadio(t)) ?? saveable[0], - ), - 0, - ); - api.savePlayQueue( - saveable.map((t) => t.id), - savePos, - current && !isRadio(current) - ? Math.floor(player.time * 1000) - : undefined, - ); - }, 1000); + $effect(() => { + queue.keys.join(","); + queue.pos; + auth.loggedIn; + ui.busy; + offlineManager.isOfflineMode; + scheduleSave(); + }); + + $effect(() => { + if (!player.paused || !player.track || player.loading) return; + player.time; + scheduleSave(); }); + + return () => { + destroyed = true; + pendingSave = undefined; + clearTimeout(saveTimer); + }; }); + +if (import.meta.hot) import.meta.hot.dispose(destroyQueueEffects);