diff options
| author | Fuwn <[email protected]> | 2026-03-01 14:20:08 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-03-01 15:24:03 -0800 |
| commit | 3b10a1f47fd5838fe3b94c19673a52610b88cf1e (patch) | |
| tree | d468a1fc12290e38686b255194ff6596b58cbf01 /src/lib | |
| parent | perf(match): fast-path exact normalised title matches (diff) | |
| download | due.moe-3b10a1f47fd5838fe3b94c19673a52610b88cf1e.tar.xz due.moe-3b10a1f47fd5838fe3b94c19673a52610b88cf1e.zip | |
perf: optimise list hot paths and shared timers
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/List/Anime/CleanAnimeList.svelte | 83 | ||||
| -rw-r--r-- | src/lib/List/Anime/DueAnimeList.svelte | 47 | ||||
| -rw-r--r-- | src/lib/List/CleanGrid.svelte | 4 | ||||
| -rw-r--r-- | src/lib/List/CleanList.svelte | 4 | ||||
| -rw-r--r-- | src/lib/List/Manga/MangaListTemplate.svelte | 45 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/AiringTime.svelte | 180 | ||||
| -rw-r--r-- | src/lib/Utility/html.ts | 109 |
7 files changed, 296 insertions, 176 deletions
diff --git a/src/lib/List/Anime/CleanAnimeList.svelte b/src/lib/List/Anime/CleanAnimeList.svelte index 561b3b1d..aee035a4 100644 --- a/src/lib/List/Anime/CleanAnimeList.svelte +++ b/src/lib/List/Anime/CleanAnimeList.svelte @@ -37,7 +37,8 @@ export let limit: number | undefined = undefined; let showRoulette = false; - let keyCacher: ReturnType<typeof setInterval>; + let airingRefreshTimeout: ReturnType<typeof setTimeout> | undefined; + let scheduledAiringAt: number | null = null; let totalEpisodeDueCount = media .map((anime) => { if ($settings.displayTotalEpisodes && !$settings.displayTotalDueEpisodes) return 1; @@ -81,35 +82,61 @@ ? media : media.filter((m) => m.mediaListEntry?.customLists?.[selectedList]); - onMount(async () => { - if (dummy) return; + const clearAiringRefreshTimeout = () => { + if (airingRefreshTimeout) clearTimeout(airingRefreshTimeout); + + airingRefreshTimeout = undefined; + scheduledAiringAt = null; + }; + + const scheduleAiringRefresh = () => { + if (!browser) return; + + if (dummy || media.length === 0) { + clearAiringRefreshTimeout(); + + return; + } + + const nextAiringAt = media.reduce<number | null>((closest, currentMedia) => { + if (currentMedia.status !== 'RELEASING' && currentMedia.status !== 'NOT_YET_RELEASED') + return closest; + + const airingAt = currentMedia.nextAiringEpisode?.airingAt; + + if (!airingAt) return closest; + if (closest === null) return airingAt; + + return airingAt < closest ? airingAt : closest; + }, null); - keyCacher = setInterval( + if (!nextAiringAt) { + clearAiringRefreshTimeout(); + + return; + } + + if (airingRefreshTimeout && scheduledAiringAt === nextAiringAt) return; + + clearAiringRefreshTimeout(); + scheduledAiringAt = nextAiringAt; + airingRefreshTimeout = setTimeout( () => { - media = media; + const now = Date.now() / 1000; - if ( - media.some( - (m) => m.nextAiringEpisode?.airingAt && m.nextAiringEpisode.airingAt < Date.now() / 1000 - ) - ) + if (media.some((m) => m.nextAiringEpisode?.airingAt && m.nextAiringEpisode.airingAt < now)) animeLists = cleanCache(user, $identity); + + scheduleAiringRefresh(); }, - (() => { - const airingAt = media - .filter( - (m) => - (m.status === 'RELEASING' || m.status === 'NOT_YET_RELEASED') && - m.nextAiringEpisode?.airingAt - ) - .find((m) => m.nextAiringEpisode?.airingAt)?.nextAiringEpisode?.airingAt; - const untilAiring = airingAt - ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 - : undefined; - - return untilAiring ? (untilAiring < 0 ? 1000 : untilAiring) : 1000; - })() + Math.max(1000, nextAiringAt * 1000 - Date.now() + 250) ); + }; + + onMount(async () => { + if (dummy) return; + + scheduleAiringRefresh(); if (browser) await localforage.setItem( @@ -120,7 +147,13 @@ ); }); - onDestroy(() => clearInterval(keyCacher)); + $: if (browser && !dummy) { + media; + + scheduleAiringRefresh(); + } + + onDestroy(() => clearAiringRefreshTimeout()); const increment = (anime: Media, progress: number) => { if (!dummy && pendingUpdate !== anime.id) { diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 2db8da65..0c1e128a 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -16,27 +16,48 @@ let animeLists: Promise<Media[]>; let startTime: number; let endTime: number; - - const keyCacher = setInterval( - () => { - startTime = performance.now(); - endTime = -1; - animeLists = mediaListCollection(user, $identity, Type.Anime, $anime, $lastPruneTimes.anime, { - forcePrune: true, - addNotification - }); - }, - $settings.cacheMinutes * 1000 * 60 - ); + let keyCacher: ReturnType<typeof setInterval> | undefined; + let keyCacheMinutes = -1; + + const restartKeyCacher = (cacheMinutes: number) => { + if (keyCacher) clearInterval(keyCacher); + + keyCacheMinutes = cacheMinutes; + keyCacher = setInterval( + () => { + startTime = performance.now(); + endTime = -1; + animeLists = mediaListCollection( + user, + $identity, + Type.Anime, + $anime, + $lastPruneTimes.anime, + { + forcePrune: true, + addNotification + } + ); + }, + cacheMinutes * 1000 * 60 + ); + }; onMount(async () => { + restartKeyCacher($settings.cacheMinutes); + startTime = performance.now(); animeLists = mediaListCollection(user, $identity, Type.Anime, $anime, $lastPruneTimes.anime, { addNotification }); }); - onDestroy(() => clearInterval(keyCacher)); + $: if (keyCacher && keyCacheMinutes !== $settings.cacheMinutes) + restartKeyCacher($settings.cacheMinutes); + + onDestroy(() => { + if (keyCacher) clearInterval(keyCacher); + }); const cleanMedia = ( anime: Media[], diff --git a/src/lib/List/CleanGrid.svelte b/src/lib/List/CleanGrid.svelte index 1e6f5a12..4f628c3c 100644 --- a/src/lib/List/CleanGrid.svelte +++ b/src/lib/List/CleanGrid.svelte @@ -17,7 +17,7 @@ let uniqueID = new Date().getTime(); - $: sortedMedia = reverseSort ? media.reverse() : media; + $: sortedMedia = reverseSort ? [...media].reverse() : media; $: processedMedia = limit !== undefined ? sortedMedia.slice(0, limit) : sortedMedia; </script> @@ -25,7 +25,7 @@ class="covers" style={`grid-template-columns: repeat(auto-fill, minmax(${$settings.displayCoverWidth}px, 1fr))`} > - {#each processedMedia as title, index} + {#each processedMedia as title, index (title.id)} {@const progress = (title.mediaListEntry || { progress: 0 }).progress} {@const isAboveFold = index < 6} diff --git a/src/lib/List/CleanList.svelte b/src/lib/List/CleanList.svelte index 63656ab3..bf8c44ff 100644 --- a/src/lib/List/CleanList.svelte +++ b/src/lib/List/CleanList.svelte @@ -12,11 +12,11 @@ export let lastUpdatedMedia: number; export let reverseSort = false; - $: processedMedia = reverseSort ? media.reverse() : media; + $: processedMedia = reverseSort ? [...media].reverse() : media; </script> <ul> - {#each processedMedia as title} + {#each processedMedia as title (title.id)} {@const progress = (title.mediaListEntry || { progress: 0 }).progress} {#if type === 'anime' ? upcoming || notYetReleased || progress !== (title.nextAiringEpisode?.episode || 9999) - 1 : progress !== title.episodes} diff --git a/src/lib/List/Manga/MangaListTemplate.svelte b/src/lib/List/Manga/MangaListTemplate.svelte index c2fc0513..f549496d 100644 --- a/src/lib/List/Manga/MangaListTemplate.svelte +++ b/src/lib/List/Manga/MangaListTemplate.svelte @@ -45,19 +45,35 @@ let rateLimited = false; let forceFlag = false; let lastListSize = 5; - - const keyCacher = setInterval( - () => { - startTime = performance.now(); - endTime = -1; - mangaLists = mediaListCollection(user, $identity, Type.Manga, $manga, $lastPruneTimes.manga, { - addNotification - }); - }, - $settings.cacheMinutes * 1000 * 60 - ); + let keyCacher: ReturnType<typeof setInterval> | undefined; + let keyCacheMinutes = -1; + + const restartKeyCacher = (cacheMinutes: number) => { + if (keyCacher) clearInterval(keyCacher); + + keyCacheMinutes = cacheMinutes; + keyCacher = setInterval( + () => { + startTime = performance.now(); + endTime = -1; + mangaLists = mediaListCollection( + user, + $identity, + Type.Manga, + $manga, + $lastPruneTimes.manga, + { + addNotification + } + ); + }, + cacheMinutes * 1000 * 60 + ); + }; onMount(async () => { + restartKeyCacher(Math.max($settings.cacheMangaMinutes, 5)); + if (browser) { const lastStoredList = (await localforage.getItem( `last${due ? '' : 'Completed'}MangaListLength` @@ -96,7 +112,12 @@ } }); - onDestroy(() => clearInterval(keyCacher)); + $: if (keyCacher && keyCacheMinutes !== Math.max($settings.cacheMangaMinutes, 5)) + restartKeyCacher(Math.max($settings.cacheMangaMinutes, 5)); + + onDestroy(() => { + if (keyCacher) clearInterval(keyCacher); + }); const cleanMedia = async (manga: Media[], displayUnresolved: boolean, force: boolean) => { progress = 0; diff --git a/src/lib/Media/Anime/Airing/AiringTime.svelte b/src/lib/Media/Anime/Airing/AiringTime.svelte index 8cf1cacc..ff888629 100644 --- a/src/lib/Media/Anime/Airing/AiringTime.svelte +++ b/src/lib/Media/Anime/Airing/AiringTime.svelte @@ -1,10 +1,10 @@ <script lang="ts"> import type { Media } from '$lib/Data/AniList/media'; import settings from '$stores/settings'; - import { onDestroy, onMount } from 'svelte'; import type { MediaPrequel } from '$lib/Data/AniList/prequels'; import tooltip from '$lib/Tooltip/tooltip'; import locale from '$stores/locale'; + import airingNow from '$stores/airingNow'; export let originalAnime: Media; export let upcoming = false; @@ -16,109 +16,105 @@ let nextEpisode = anime.nextAiringEpisode?.episode || 0; let few = true; let dateString = ''; - let updateInterval: ReturnType<typeof setInterval>; - - onMount(() => { - const setAiringTime = () => { - time = ''; - timeFrame = ''; - dateString = ''; - - const airingAt = anime.nextAiringEpisode?.airingAt; - const untilAiring = airingAt - ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 - : undefined; - let hours = null; - const shortenCountdown = $settings.displayShortCountdown; - - time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { - hour12: !$settings.display24HourTime, - hour: 'numeric', - minute: '2-digit' - }); - - if ( - (anime as unknown as MediaPrequel).startDate && - new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ) < new Date() - ) - return `<span class="opaque">on ${new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ).toLocaleDateString()}</span>`; - - if (untilAiring !== undefined) { - let minutes = Math.round(untilAiring / 60); - - few = true; - - if (minutes > 60) { - hours = minutes / 60; - - if (hours > 24) { - const days = Math.floor(hours / 24); - const weeks = Math.floor(days / 7); - - few = false; - - if (weeks >= 1.5) { - timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${ - weeks === 1 || shortenCountdown ? '' : 's' - }`; - - const residualDays = days % 7; - - if (residualDays > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualDays}${ - shortenCountdown ? 'd' : ' day' - }${residualDays === 1 || shortenCountdown ? '' : 's'}`; - } else { - timeFrame += `${days}${shortenCountdown ? 'd' : ' day'}${ - days === 1 || shortenCountdown ? '' : 's' - }`; - } - - const residualHours = Math.floor(hours - days * 24); - - if (residualHours > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${ - shortenCountdown ? 'h' : ' hour' - }${residualHours === 1 || shortenCountdown ? '' : 's'}`; - } else { - const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); - - timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${ - Math.floor(hours) === 1 || shortenCountdown ? '' : 's' + const setAiringTime = () => { + time = ''; + timeFrame = ''; + dateString = ''; + + const airingAt = anime.nextAiringEpisode?.airingAt; + const untilAiring = airingAt + ? Math.round((airingAt - $airingNow / 1000) * 100) / 100 + : undefined; + let hours = null; + const shortenCountdown = $settings.displayShortCountdown; + + time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { + hour12: !$settings.display24HourTime, + hour: 'numeric', + minute: '2-digit' + }); + + if ( + (anime as unknown as MediaPrequel).startDate && + new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day + ) < new Date() + ) + return `<span class="opaque">on ${new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day + ).toLocaleDateString()}</span>`; + + if (untilAiring !== undefined) { + let minutes = Math.round(untilAiring / 60); + + few = true; + + if (minutes > 60) { + hours = minutes / 60; + + if (hours > 24) { + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + + few = false; + + if (weeks >= 1.5) { + timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${ + weeks === 1 || shortenCountdown ? '' : 's' }`; - if (residualMinutes > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${ - shortenCountdown ? 'm' : ' minute' - }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`; + const residualDays = days % 7; + + if (residualDays > 0) + timeFrame += `${shortenCountdown ? '' : ' '}${residualDays}${ + shortenCountdown ? 'd' : ' day' + }${residualDays === 1 || shortenCountdown ? '' : 's'}`; + } else { + timeFrame += `${days}${shortenCountdown ? 'd' : ' day'}${ + days === 1 || shortenCountdown ? '' : 's' + }`; } + + const residualHours = Math.floor(hours - days * 24); + + if (residualHours > 0) + timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${ + shortenCountdown ? 'h' : ' hour' + }${residualHours === 1 || shortenCountdown ? '' : 's'}`; } else { - minutes = Math.round(minutes); + const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); - timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${ - minutes === 1 || shortenCountdown ? '' : 's' + timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${ + Math.floor(hours) === 1 || shortenCountdown ? '' : 's' }`; + + if (residualMinutes > 0) + timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${ + shortenCountdown ? 'm' : ' minute' + }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`; } + } else { + minutes = Math.round(minutes); - opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); - dateString = $locale().dateFormatter(new Date(airingAt ? airingAt * 1000 : 0)); + timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${ + minutes === 1 || shortenCountdown ? '' : 's' + }`; } - }; - setAiringTime(); + opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); + dateString = $locale().dateFormatter(new Date(airingAt ? airingAt * 1000 : 0)); + } + }; - updateInterval = setInterval(setAiringTime, 30000); - }); + $: { + $airingNow; - onDestroy(() => clearInterval(updateInterval)); + setAiringTime(); + } </script> {#if upcoming} diff --git a/src/lib/Utility/html.ts b/src/lib/Utility/html.ts index 8d60d3b6..ccdfae8d 100644 --- a/src/lib/Utility/html.ts +++ b/src/lib/Utility/html.ts @@ -4,39 +4,88 @@ import { get } from 'svelte/store'; export const nbsp = (str: string) => str.replace(/ /g, ' '); export const createHeightObserver = (details = true) => { - document.querySelectorAll('.list').forEach((element) => { - if ( - !( - element as unknown as { - dataset: { observed: string }; - } - ).dataset.observed - ) { - new ResizeObserver((entries) => { - entries.forEach((entry) => { - const element = entry.target as HTMLElement; - - if (get(settings).displayLimitListHeight) { - element.style.height = 'auto'; - - const elementBound = element.getBoundingClientRect(); - const height = window.innerHeight - elementBound.top - 2.5 * 16; - - if (elementBound.height > height) element.style.height = `${height}px`; - } - }); - }).observe(element); + const observedElements = new Set<HTMLElement>(); + const resizeObservers = new Map<HTMLElement, ResizeObserver>(); + const detailObservers = new Map<HTMLElement, MutationObserver>(); + + const applyHeightLimit = (target: HTMLElement) => { + if (!get(settings).displayLimitListHeight) { + target.style.height = 'auto'; + + return; + } + + target.style.height = 'auto'; + + const elementBound = target.getBoundingClientRect(); + const height = window.innerHeight - elementBound.top - 2.5 * 16; - if (details) - new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - const element = mutation.target as HTMLDetailsElement; + if (elementBound.height > height) target.style.height = `${height}px`; + }; - if (element.tagName === 'DETAILS' && !element.open) element.style.height = 'auto'; - }); - }).observe(element, { attributes: true }); + const observeElement = (element: HTMLElement) => { + if (element.dataset.observed) return; + + const resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const target = entry.target as HTMLElement; + + applyHeightLimit(target); + }); + }); + + resizeObserver.observe(element); + resizeObservers.set(element, resizeObserver); + + if (details) { + const detailsObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const target = mutation.target as HTMLDetailsElement; + + if (target.tagName === 'DETAILS' && !target.open) target.style.height = 'auto'; + }); + }); - element.setAttribute('data-observed', 'true'); + detailsObserver.observe(element, { attributes: true }); + detailObservers.set(element, detailsObserver); } + + element.dataset.observed = 'true'; + + observedElements.add(element); + applyHeightLimit(element); + }; + + document.querySelectorAll<HTMLElement>('.list').forEach(observeElement); + + const mutationObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (!(node instanceof HTMLElement)) return; + + if (node.matches('.list')) observeElement(node); + + node.querySelectorAll<HTMLElement>('.list').forEach(observeElement); + }); + }); }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); + + const unsubscribeSettings = settings.subscribe(() => { + observedElements.forEach((element) => applyHeightLimit(element)); + }); + + return () => { + unsubscribeSettings(); + mutationObserver.disconnect(); + resizeObservers.forEach((observer) => observer.disconnect()); + detailObservers.forEach((observer) => observer.disconnect()); + + observedElements.forEach((element) => { + element.style.height = 'auto'; + + delete element.dataset.observed; + }); + }; }; |