aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-01 14:20:08 -0800
committerFuwn <[email protected]>2026-03-01 15:24:03 -0800
commit3b10a1f47fd5838fe3b94c19673a52610b88cf1e (patch)
treed468a1fc12290e38686b255194ff6596b58cbf01 /src/lib
parentperf(match): fast-path exact normalised title matches (diff)
downloaddue.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.svelte83
-rw-r--r--src/lib/List/Anime/DueAnimeList.svelte47
-rw-r--r--src/lib/List/CleanGrid.svelte4
-rw-r--r--src/lib/List/CleanList.svelte4
-rw-r--r--src/lib/List/Manga/MangaListTemplate.svelte45
-rw-r--r--src/lib/Media/Anime/Airing/AiringTime.svelte180
-rw-r--r--src/lib/Utility/html.ts109
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, '&nbsp;');
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;
+ });
+ };
};