From d0f562452a442de0889de5fd5f33063c25663913 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Thu, 25 Jul 2024 02:16:38 +0800 Subject: [PATCH] fix: potential UI bugs in lyrics component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .idea for WebStorm users - Fix typo in a function name - Optimize display logic of lyrics component’s debug mode - Now you can use Cmd+Opt+Shift+Enter to toggle debug mode - Improve async logic in lyrics' script - Standardize naming in lyrics' script - Add detailed comments in lyrics component - Use CSS variables to enhance styles for lyric lines - Refactor nextUpdate to a Svelte store and subscribe to its changes - Avoid detecting changes in frequently triggered Svelte reactivity statement (line 148 in origin) - [TODO] Improve the interactive logic of the progress bar --- .github/workflows/main.yml | 4 - .idea/.gitignore | 5 + .idea/AquaVox.iml | 12 + .idea/codeStyles/Project.xml | 48 +++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/jsLibraryMappings.xml | 6 + .idea/modules.xml | 8 + .idea/runConfigurations/Run_Dev_Server.xml | 17 + .idea/vcs.xml | 6 + package.json | 2 +- src/lib/components/base.svelte | 3 - src/lib/components/interactiveBox.svelte | 178 +++++----- src/lib/components/lyrics.svelte | 358 ++++++++++++++------- src/lib/state/nextUpdate.ts | 3 + 14 files changed, 450 insertions(+), 205 deletions(-) delete mode 100644 .github/workflows/main.yml create mode 100644 .idea/.gitignore create mode 100644 .idea/AquaVox.iml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/jsLibraryMappings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations/Run_Dev_Server.xml create mode 100644 .idea/vcs.xml delete mode 100644 src/lib/components/base.svelte create mode 100644 src/lib/state/nextUpdate.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index c00ddca..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,4 +0,0 @@ -- name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/AquaVox.iml b/.idea/AquaVox.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/AquaVox.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..4ab4363 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..48ed1ff --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Dev_Server.xml b/.idea/runConfigurations/Run_Dev_Server.xml new file mode 100644 index 0000000..8ae90f4 --- /dev/null +++ b/.idea/runConfigurations/Run_Dev_Server.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index e24b2ae..c87f4c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aquavox", - "version": "1.10.1", + "version": "1.10.2", "private": false, "scripts": { "dev": "vite dev", diff --git a/src/lib/components/base.svelte b/src/lib/components/base.svelte deleted file mode 100644 index 42eb8c5..0000000 --- a/src/lib/components/base.svelte +++ /dev/null @@ -1,3 +0,0 @@ -
- -
\ No newline at end of file diff --git a/src/lib/components/interactiveBox.svelte b/src/lib/components/interactiveBox.svelte index 9a18e41..0bbf0e5 100644 --- a/src/lib/components/interactiveBox.svelte +++ b/src/lib/components/interactiveBox.svelte @@ -35,11 +35,11 @@ } function volumeBarChangeTouch(e: TouchEvent) { - const value = turncate( - e.touches[0].clientX - volumeBar.getBoundingClientRect().x, - 0, - volumeBar.getBoundingClientRect().width - ) / volumeBar.getBoundingClientRect().width; + const value = truncate( + e.touches[0].clientX - volumeBar.getBoundingClientRect().x, + 0, + volumeBar.getBoundingClientRect().width + ) / volumeBar.getBoundingClientRect().width; adjustVolume(value); localStorage.setItem('volume', value.toString()); } @@ -49,10 +49,14 @@ progressBarSlideValue.set((e.offsetX / progressBar.getBoundingClientRect().width) * duration); } - function turncate(value: number, min: number, max: number) { + function truncate(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } + function progressBarMouseUp(offsetX: number) { + adjustDisplayProgress(offsetX / progressBar.getBoundingClientRect().width); + } + onMount(() => { mql.addEventListener('change', (e) => { showInfoTop = e.matches && hasLyrics; @@ -85,7 +89,7 @@ > {#if !showInfoTop}
-
+
{name}
progressBarOnClick(e)} + aria-valuemax={duration} + aria-valuemin="0" + aria-valuenow={progress} bind:this={progressBar} + class="progress-bar shadow-md" + on:keydown + on:keyup on:mousedown={() => { userAdjustingProgress.set(true); }} on:mousemove={(e) => { if ($userAdjustingProgress) { + console.log(e.offsetX ) adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width); } }} - on:touchstart={(e) => { - if (e.cancelable) { - e.preventDefault(); - } - userAdjustingProgress.set(true); - }} - on:touchmove={(e) => { - e.preventDefault(); - userAdjustingProgress.set(true); - if ($userAdjustingProgress) { - lastTouchProgress = - turncate( - e.touches[0].clientX - progressBar.getBoundingClientRect().x, - 0, - progressBar.getBoundingClientRect().width - ) / progressBar.getBoundingClientRect().width; - adjustDisplayProgress(lastTouchProgress); - } - }} - on:touchend={(e) => { - e.preventDefault(); - userAdjustingProgress.set(false); - adjustProgress(lastTouchProgress); - }} - on:mouseup={() => { - userAdjustingProgress.set(false); + on:mouseup={(e) => { + const offsetX = e.offsetX; + progressBarOnClick(e); + // Q: why it needs delay? + // A: I do not know. + setTimeout(()=> { + userAdjustingProgress.set(false); + progressBarMouseUp(offsetX); + }, 50); }} role="slider" - aria-valuemin="0" - aria-valuemax={duration} - aria-valuenow={progress} tabindex="0" - on:keydown - on:keyup >
@@ -152,26 +139,49 @@
{formatDuration(duration)}
- -
- 最小音量 + 最小音量
volumeBarOnChange(e)} - bind:this={volumeBar} + on:keydown + on:keyup on:mousedown={() => { userAdjustingVolume = true; }} @@ -180,11 +190,12 @@ volumeBarOnChange(e); } }} - on:touchstart={(e) => { - if (e.cancelable) { - e.preventDefault(); - } - userAdjustingVolume = true; + on:mouseup={() => { + userAdjustingVolume = false; + }} + on:touchend={(e) => { + e.preventDefault(); + userAdjustingVolume = false; }} on:touchmove={(e) => { e.preventDefault(); @@ -193,24 +204,18 @@ volumeBarChangeTouch(e); } }} - on:touchend={(e) => { - e.preventDefault(); - userAdjustingVolume = false; - }} - on:mouseup={() => { - userAdjustingVolume = false; + on:touchstart={(e) => { + if (e.cancelable) { + e.preventDefault(); + } + userAdjustingVolume = true; }} role="slider" - aria-valuemin="0" - aria-valuemax="1" - aria-valuenow={volume} tabindex="0" - on:keydown - on:keyup >
- 最大音量 + 最大音量
@@ -221,6 +226,7 @@ left: 50%; transform: translate(-50%, 0); } + .control-btn { display: inline-block; height: 3.7rem; @@ -228,11 +234,10 @@ cursor: pointer; margin: 0 0.5rem; border-radius: 0.5rem; - transition: 0.1s; - } - .control-btn:hover { - background-color: rgba(0, 0, 0, 0.1); + transition: 0.45s; + scale: 1; } + .control-img { height: 2rem; width: 2rem; @@ -240,6 +245,7 @@ left: 50%; transform: translateX(-50%); } + .switch-song-img { width: auto !important; height: 1.7rem !important; @@ -256,19 +262,21 @@ font-family: sans-serif; text-align: center; } - .song-info-top { + + .song-info-regular { white-space: nowrap; overflow: hidden; position: relative; + height: 2.375rem; } - .song-info-top.animate { + .song-info-regular.animate { mask-image: linear-gradient( - 90deg, - rgba(0, 0, 0, 0) 0%, - rgba(0, 0, 0, 1) 2rem, - rgba(0, 0, 0, 1) calc(100% - 5rem), - rgba(0, 0, 0, 0) 100% + 90deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 1) 2rem, + rgba(0, 0, 0, 1) calc(100% - 5rem), + rgba(0, 0, 0, 0) 100% ); } @@ -283,12 +291,15 @@ height: 2.5rem; display: inline-block; } + .song-name.animate { animation: scroll 10s linear infinite; } + .song-name::-webkit-scrollbar { display: none; } + @keyframes scroll { 0% { transform: translateX(100%); @@ -300,10 +311,12 @@ transform: translateX(-100%); } } + .song-author { font-size: 1.2rem; color: rgba(255, 255, 255, 0.8); } + .progress { position: absolute; width: 100%; @@ -311,6 +324,7 @@ transform: translate(-50%, 0); height: 2.4rem; } + .progress-bar { -webkit-appearance: none; appearance: none; @@ -325,9 +339,11 @@ cursor: pointer; transition: 0.3s; } + .progress-bar:hover { height: 0.7rem; } + .bar { background-color: white; position: absolute; @@ -351,10 +367,18 @@ display: inline-block; top: 0.2rem; } + .time-current { left: 0; } + .time-total { right: 0; } + + @media (min-width: 768px) { + .control-btn { + transition: 0.1s + } + } diff --git a/src/lib/components/lyrics.svelte b/src/lib/components/lyrics.svelte index 1cbfc2e..dca89a0 100644 --- a/src/lib/components/lyrics.svelte +++ b/src/lib/components/lyrics.svelte @@ -4,19 +4,32 @@ import progressBarRaw from '$lib/state/progressBarRaw'; import type { LrcJsonData } from 'lrc-parser-ts'; import progressBarSlideValue from '$lib/state/progressBarSlideValue'; + import nextUpdate from '$lib/state/nextUpdate'; + + // Component input properties export let lyrics: string[]; export let originalLyrics: LrcJsonData; export let progress: number; let getLyricIndex: Function; - const debugMode = false; + let debugMode = false; + if (localStorage.getItem('debugMode') == null) { + localStorage.setItem('debugMode', 'false'); + } + else { + debugMode = localStorage.getItem('debugMode')!.toLowerCase() === "true"; + } let currentLyricIndex = -1; let currentPositionIndex = -1; let currentAnimationIndex = -1; - let lyricsContainer: HTMLDivElement; - let nextUpdate = -1; + let lyricsContainer: HTMLDivElement | null; let lastAdjustProgress = 0; let localProgress = 0; + let lastScroll = 0; + let scrolling = false; + let scriptScrolling = false; + + let currentLyricTopMargin = 288; let refs: HTMLParagraphElement[] = []; let _refs: any[] = []; @@ -30,57 +43,37 @@ else return 'previous-lyric'; } - $: { - if (lyricsContainer && originalLyrics && originalLyrics.scripts) { - const scripts = originalLyrics.scripts; - currentPositionIndex = getLyricIndex(progress); - const cl = scripts[currentPositionIndex]; - if (cl.start <= progress && progress <= cl.end) { - currentLyricIndex = currentPositionIndex; - nextUpdate = cl.end; - } else { - currentLyricIndex = -1; - nextUpdate = cl.start; - } - if ($userAdjustingProgress === false) { - for (let i = 0; i < scripts.length; i++) { - const offset = Math.abs(i - currentPositionIndex); - const blurRadius = Math.min(offset * 1.5, 16); - if (refs[i]) { - refs[i].style.filter = `blur(${blurRadius}px)`; - } - } - } - } - } + // Function to move the lyrics up smoothly + async function moveToNextLine(h: number) { + console.debug(new Date().getTime() , 'moveToNextLine', h); + // the line that's going to process (like a pointer) + // by default, it's "the next line" after the lift + let processingLineIndex = currentPositionIndex + 2; - function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - async function a(h: number) { - let pos = currentPositionIndex + 2; - for (let i = currentPositionIndex + 2; i < refs.length; i++) { + // modify translateY of all lines in viewport one by one to lift them up + for (let i = processingLineIndex; i < refs.length; i++) { const lyric = refs[i]; lyric.style.transition = - 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; + `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, + font-size 200ms ease, scale 250ms ease`; lyric.style.transform = `translateY(${-h}px)`; - pos = i; + processingLineIndex = i; await sleep(75); - if (refs[i - 2].getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break; + const twoLinesAhead = refs[i - 2]; + if (lyricsContainer && twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break; } - // 特判,鬼知道为什么 - if (refs.length - pos < 3) { - for (let i = pos; i < refs.length; i++) { + + if (refs.length - processingLineIndex < 3) { + for (let i = processingLineIndex; i < refs.length; i++) { const lyric = refs[i]; lyric.style.transition = 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; lyric.style.transform = `translateY(${-h}px)`; - pos = i; + processingLineIndex = i; await sleep(75); } } else { - for (let i = pos; i < refs.length; i++) { + for (let i = processingLineIndex; i < refs.length; i++) { refs[i].style.transition = 'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; const h = refs[i].getBoundingClientRect().height; @@ -88,27 +81,44 @@ } } + // wait until the animation end await sleep(650); + + // clear the transition to let the following style changes could be done without animation for (let i = 0; i < refs.length; i++) { refs[i].style.transition = 'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; } + // reset the translateY, and immediately scroll down to provide visual stability for (let i = 0; i < refs.length; i++) { refs[i].style.transform = `translateY(0px)`; } - lyricsContainer.scrollTop += h; + scriptScrolling = true; + if (lyricsContainer !== null) { + lyricsContainer.scrollTop += h; + } + await sleep(500); + scriptScrolling = false; } async function b(currentLyric: HTMLParagraphElement) { if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return; - lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - 144; + scriptScrolling = true; + lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - currentLyricTopMargin; + for (let i = 0; i < refs.length; i++) { + refs[i].style.transform = 'translateY(0px)'; + } + setTimeout(() => { + scriptScrolling = false; + }, 500); } - userAdjustingProgress.subscribe((v) => { + // Handle user adjusting progress state changes + userAdjustingProgress.subscribe((adjusting) => { if (!originalLyrics) return; const scripts = originalLyrics.scripts; if (!scripts) return; - if (v) { + if (adjusting) { for (let i = 0; i < scripts.length; i++) { refs[i].style.filter = `blur(0px)`; } @@ -141,63 +151,150 @@ } }); - $: { - if ($userAdjustingProgress) { - nextUpdate = progress; - } else { - if (nextUpdate - progress < 0.05) { - if ( - currentPositionIndex >= 0 && - currentPositionIndex !== currentAnimationIndex && - currentPositionIndex !== lastAdjustProgress - ) { - const offsetHeight = - refs[currentPositionIndex].getBoundingClientRect().height + - refs[currentPositionIndex].getBoundingClientRect().top - - 144; - const currentLyric = refs[currentPositionIndex]; - currentLyric.style.transition = - 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; - currentLyric.style.transform = `translateY(${-offsetHeight}px)`; - - for (let i = currentPositionIndex - 1; i >= 0; i--) { - refs[i].style.transition = - 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; - const h = refs[i].getBoundingClientRect().height; - refs[i].style.transform = `translateY(${-offsetHeight}px)`; - } - if (currentPositionIndex + 1 < refs.length) { - const nextLyric = refs[currentPositionIndex + 1]; - nextLyric.style.transition = - 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; - nextLyric.style.transform = `translateY(${-offsetHeight}px)`; - a(offsetHeight); - } - currentAnimationIndex = currentPositionIndex; + // Handle scroll events in the lyrics container + function scrollHandler() { + scrolling = !scriptScrolling; + if (scrolling && originalLyrics.scripts) { + lastScroll = new Date().getTime(); + for (let i = 0; i < originalLyrics.scripts.length; i++) { + if (refs[i]) { + refs[i].style.filter = 'blur(0px)'; } } } + setTimeout(() => { + if (new Date().getTime() - lastScroll > 5000) { + scrolling = false; + } + }, 5500); } + + // Utility function to create a sleep/delay + function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // Scroll to corresponding lyric while adjusting progress + $: { + if ($userAdjustingProgress == true) { + const currentLyric = refs[getLyricIndex(progress)]; + scrollToLyric(currentLyric); + } + } + + // Update the current lyric and apply blur effect based on the progress + $: { + (() => { + if (!lyricsContainer || !originalLyrics.scripts) return; + + const scripts = originalLyrics.scripts; + currentPositionIndex = getLyricIndex(progress); + const cl = scripts[currentPositionIndex]; + + if (cl.start <= progress && progress <= cl.end) { + currentLyricIndex = currentPositionIndex; + nextUpdate.set(cl.end); + } else { + currentLyricIndex = -1; + nextUpdate.set(cl.start); + } + + const currentLyric = refs[currentPositionIndex]; + if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return; + + for (let i = 0; i < scripts.length; i++) { + const offset = Math.abs(i - currentPositionIndex); + const blurRadius = Math.min(offset * 0.96, 16); + if (refs[i]) { + refs[i].style.filter = `blur(${blurRadius}px)`; + } + } + })(); + } + + nextUpdate.subscribe(async (nextUpdate) => { + if ( + currentPositionIndex < 0 || + currentPositionIndex === currentAnimationIndex || + currentPositionIndex === lastAdjustProgress || + $userAdjustingProgress === true || + scrolling + ) return; + + const currentLyric = refs[currentPositionIndex]; + + if (originalLyrics.scripts && currentLyric.getBoundingClientRect().top < 0) return; + + const offsetHeight = + refs[currentPositionIndex].getBoundingClientRect().top - + currentLyricTopMargin; + + // prepare current line + currentLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; + currentLyric.style.transform = `translateY(${-offsetHeight}px)`; + + for (let i = currentPositionIndex - 1; i >= 0; i--) { + refs[i].style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; + refs[i].style.transform = `translateY(${-offsetHeight}px)`; + } + if (currentPositionIndex + 1 < refs.length) { + const nextLyric = refs[currentPositionIndex + 1]; + nextLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; + nextLyric.style.transform = `translateY(${-offsetHeight}px)`; + await moveToNextLine(offsetHeight); + } + currentAnimationIndex = currentPositionIndex; + }) + + function onKeyDown(e: KeyboardEvent) { + if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') { + debugMode = !debugMode; + localStorage.setItem('debugMode', debugMode ? 'true' : 'false'); + } + } + + function extractTranslateValue(s: string): string | null { + const regex = /translateY\((-?\d*px)\)/; + let arr = regex.exec(s); + return arr==null ? null : arr[1]; + } + -{#if lyrics && originalLyrics} + + +{#if debugMode && lyricsContainer} +
+

+ LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex} + AnimationIndex:{currentAnimationIndex} + NextUpdate: {$nextUpdate} + Progress: {progress.toFixed(2)} + lastAdjustProgress: {lastAdjustProgress} + scrollPosition: {lyricsContainer.scrollTop} +

+
+{/if} + + +{#if lyrics && originalLyrics && originalLyrics.scripts}
- {#if debugMode} -

- LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex} AnimationIndex:{currentAnimationIndex} - NextUpdate: {nextUpdate} - Progress: {progress.toFixed(2)} - lastAdjustProgress: {lastAdjustProgress} -

- {/if} {#each lyrics as lyric, i}

- {#if debugMode} - {i} + {#if debugMode && refs[i] && refs[i].style !== undefined} + {i}   + {originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end} + tY: {extractTranslateValue(refs[i].style.transform)} + top: {Math.round(refs[i].getBoundingClientRect().top)}px + {/if} {lyric}

@@ -206,82 +303,103 @@
{/if} + diff --git a/src/lib/state/nextUpdate.ts b/src/lib/state/nextUpdate.ts new file mode 100644 index 0000000..1ef1ac9 --- /dev/null +++ b/src/lib/state/nextUpdate.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; +const nextUpdate = writable(-1); +export default nextUpdate; \ No newline at end of file