Chore: ModalBlur Component

This commit is contained in:
Youwes09
2026-06-09 21:08:57 -05:00
parent abd60f261f
commit 915ff66b2f
8 changed files with 82 additions and 29 deletions
+13 -3
View File
@@ -1,13 +1,15 @@
import { readerState } from "$lib/state/reader.svelte"; import { readerState } from "$lib/state/reader.svelte";
import { fetchPages } from "./pageLoader"; import { fetchPages } from "./pageLoader";
import { cancelQueuedFetches, revokeBlobUrl } from "$lib/core/cache/imageCache"; import { cancelQueuedFetches, revokeBlobUrl } from "$lib/core/cache/imageCache";
import { clearResolvedUrlCache } from "$lib/core/cache/pageCache"; import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
export function scheduleResumeDismiss() { export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500); setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
} }
let prefetchedChapterId: number | null = null;
export async function loadChapter( export async function loadChapter(
id: number, id: number,
useBlob: boolean, useBlob: boolean,
@@ -23,11 +25,16 @@ export async function loadChapter(
cancelQueuedFetches(); cancelQueuedFetches();
if (useBlob) { if (useBlob) {
clearResolvedUrlCache(); clearResolvedUrlCache();
// revoke blob URLs for all loaded pages so the GPU can release their textures
for (const url of readerState.pageUrls) revokeBlobUrl(url); for (const url of readerState.pageUrls) revokeBlobUrl(url);
for (const strip of readerState.stripChapters) { for (const strip of readerState.stripChapters) {
for (const url of strip.urls) revokeBlobUrl(url); for (const url of strip.urls) revokeBlobUrl(url);
} }
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
const prefetchedUrls = await fetchPages(prefetchedChapterId, false).catch(() => [] as string[]);
for (const url of prefetchedUrls) revokeBlobUrl(url);
clearPageCache(prefetchedChapterId);
}
prefetchedChapterId = null;
} }
startAtLastPage.current = false; startAtLastPage.current = false;
@@ -51,7 +58,10 @@ export async function loadChapter(
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo); else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true; readerState.pageReady = true;
readerState.loading = false; readerState.loading = false;
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {}); if (adjacent.next) {
prefetchedChapterId = adjacent.next.id;
fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
}
} catch (e: unknown) { } catch (e: unknown) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e); readerState.error = e instanceof Error ? e.message : String(e);
+3 -9
View File
@@ -10,9 +10,7 @@
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */ /* ── Backdrop & Modal Shell ───────────────────────────────────────── */
.s-backdrop { .s-backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
animation: s-fade-in 0.14s ease both; animation: s-fade-in 0.14s ease both;
@@ -29,10 +27,7 @@
overflow: visible; overflow: visible;
position: relative; position: relative;
animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both; animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both;
box-shadow: box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
0 0 0 1px rgba(255,255,255,0.04) inset,
0 24px 80px rgba(0,0,0,0.7),
0 8px 24px rgba(0,0,0,0.4);
} }
@@ -46,7 +41,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
overflow-y: auto; overflow-y: hidden;
border-radius: var(--radius-2xl) 0 0 var(--radius-2xl); border-radius: var(--radius-2xl) 0 0 var(--radius-2xl);
} }
@@ -140,7 +135,6 @@
.s-content-body { .s-content-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
will-change: transform;
} }
@@ -19,6 +19,7 @@
import ContentSettings from './sections/ContentSettings.svelte' import ContentSettings from './sections/ContentSettings.svelte'
import AboutSettings from './sections/AboutSettings.svelte' import AboutSettings from './sections/AboutSettings.svelte'
import DevtoolsSettings from './sections/DevToolsSettings.svelte' import DevtoolsSettings from './sections/DevToolsSettings.svelte'
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void } interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
let { onclose, onOpenThemeEditor }: Props = $props() let { onclose, onOpenThemeEditor }: Props = $props()
@@ -111,6 +112,7 @@
}) })
</script> </script>
<ModalBlur />
<div class="s-backdrop" role="presentation" tabindex="-1" <div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }} onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}> onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
@@ -20,6 +20,7 @@
} from "$lib/state/series.svelte"; } from "$lib/state/series.svelte";
import { app } from "$lib/state/app.svelte"; import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types"; import type { Manga, Chapter, Category } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
@@ -353,6 +354,7 @@
</script> </script>
{#if seriesState.previewManga} {#if seriesState.previewManga}
<ModalBlur blur={4} dim={0.72} />
<div <div
class="backdrop" class="backdrop"
role="button" role="button"
@@ -679,10 +681,8 @@
<style> <style>
.backdrop { .backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
@@ -0,0 +1,40 @@
<script lang="ts">
let {
blur = 8,
dim = 0.6,
zIndex = 'var(--z-settings)',
animate = true,
}: {
blur?: number
dim?: number
zIndex?: string | number
animate?: boolean
} = $props()
</script>
<div
class="modal-blur"
class:animate
style="--blur:{blur}px; --dim:{dim}; --z:{zIndex}"
></div>
<style>
.modal-blur {
position: fixed;
inset: 0;
backdrop-filter: blur(var(--blur));
-webkit-backdrop-filter: blur(var(--blur));
background: rgba(0, 0, 0, var(--dim));
pointer-events: none;
z-index: var(--z);
}
.modal-blur.animate {
animation: blur-in 0.14s ease both;
}
@keyframes blur-in {
from { opacity: 0 }
to { opacity: 1 }
}
</style>
@@ -10,6 +10,7 @@
import { markManyRead } from "$lib/request-manager/chapters"; import { markManyRead } from "$lib/request-manager/chapters";
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types"; import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
import type { Chapter } from "$lib/types"; import type { Chapter } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let { mangaId, mangaTitle, onClose }: { let { mangaId, mangaTitle, onClose }: {
mangaId: number; mangaId: number;
@@ -250,6 +251,7 @@
} }
}} /> }} />
<ModalBlur blur={4} dim={0.68} />
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal" role="dialog" aria-label="Tracking"> <div class="modal" role="dialog" aria-label="Tracking">
@@ -497,6 +499,7 @@
{#if confirmUnbindId !== null} {#if confirmUnbindId !== null}
{@const rec = records.find(r => r.id === confirmUnbindId)} {@const rec = records.find(r => r.id === confirmUnbindId)}
{@const trk = rec ? trackerFor(rec.trackerId) : null} {@const trk = rec ? trackerFor(rec.trackerId) : null}
<ModalBlur blur={2} dim={0.45} zIndex="calc(var(--z-settings) + 1)" />
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel" <div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel"
onclick={() => confirmUnbindId = null} onclick={() => confirmUnbindId = null}
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}> onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
@@ -515,10 +518,9 @@
<style> <style>
.backdrop { .backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.68); position: fixed; inset: 0;
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
@@ -646,7 +648,7 @@
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; } .confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; } .confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; } .confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); } .confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
+9 -7
View File
@@ -7,7 +7,7 @@ const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
let drainScheduled = false; let drainScheduled = false;
let clearing = false; let generation = 0;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
@@ -32,10 +32,11 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
return {}; return {};
} }
async function doFetch(url: string): Promise<string> { async function doFetch(url: string, gen: number): Promise<string> {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
const blob = await platformService.fetchImage(url, headers); const blob = await platformService.fetchImage(url, headers);
if (clearing) throw new DOMException("Cancelled", "AbortError"); if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl); cache.set(url, blobUrl);
return blobUrl; return blobUrl;
@@ -55,8 +56,9 @@ function drain() {
drainScheduled = false; drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) { while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!; const entry = queue.shift()!;
const gen = generation;
active++; active++;
doFetch(entry.url) doFetch(entry.url, gen)
.then(entry.resolve, entry.reject) .then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); }); .finally(() => { active--; drain(); });
} }
@@ -107,6 +109,7 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
export function revokeBlobUrl(url: string): void { export function revokeBlobUrl(url: string): void {
const blob = cache.get(url); const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); } if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
inflight.delete(url);
} }
export function deprioritizeQueue(): void { export function deprioritizeQueue(): void {
@@ -123,10 +126,9 @@ export function cancelQueuedFetches(): void {
} }
export function clearBlobCache(): void { export function clearBlobCache(): void {
clearing = true; generation++;
cancelQueuedFetches(); cancelQueuedFetches();
inflight.clear();
cache.forEach(blob => URL.revokeObjectURL(blob)); cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); cache.clear();
inflight.clear();
clearing = false;
} }
+4 -1
View File
@@ -1,4 +1,4 @@
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache"; import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
import { settingsState } from "$lib/state/settings.svelte"; import { settingsState } from "$lib/state/settings.svelte";
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
@@ -90,6 +90,9 @@ export function preloadImage(url: string, useBlob: boolean): void {
} }
export function clearResolvedUrlCache(): void { export function clearResolvedUrlCache(): void {
for (const promise of resolvedUrlCache.values()) {
promise.then(blobUrl => { if (blobUrl) revokeBlobUrl(blobUrl); }).catch(() => {});
}
resolvedUrlCache.clear(); resolvedUrlCache.clear();
aspectCache.clear(); aspectCache.clear();
} }