diff --git a/frontend/src/components/features/upload/upload-form/index.tsx b/frontend/src/components/features/upload/upload-form/index.tsx index f22dde0f..4b2c1de4 100644 --- a/frontend/src/components/features/upload/upload-form/index.tsx +++ b/frontend/src/components/features/upload/upload-form/index.tsx @@ -26,7 +26,6 @@ import { I18n } from "@/locales/i18n"; import { logger } from "@/lib/logger-client"; import { eventBus, EVENTS } from "@/lib/events"; import { processAudioFileStream } from "@/lib/utils/stream-processor"; -import { cacheAudioForOffline, cacheCoverForOffline } from "@/lib/pwa/cache-manager"; import { api } from "@/services"; import type { LibraryOption } from "@/types/models"; import { LibraryBig } from "lucide-react"; @@ -134,7 +133,9 @@ export function UploadSongForm({ onDrawerClose, targetLibraryId }: UploadSongFor const songTitle = data.song.title || file.name; // Cache audio and cover for offline use (non-blocking, fail silently) + // Dynamic import to avoid loading cache-manager in initial bundle try { + const { cacheAudioForOffline, cacheCoverForOffline } = await import("@/lib/pwa/cache-manager"); await cacheAudioForOffline(songId, file); if (coverBlob) { await cacheCoverForOffline(songId, coverBlob); diff --git a/frontend/src/components/providers/auth-provider.tsx b/frontend/src/components/providers/auth-provider.tsx index 18713e48..acc1d39c 100644 --- a/frontend/src/components/providers/auth-provider.tsx +++ b/frontend/src/components/providers/auth-provider.tsx @@ -9,6 +9,7 @@ import { startAutoSync, stopAutoSync } from "@/lib/sync/metadata-sync"; import { triggerAutoDownload } from "@/lib/storage/download-manager"; import { useAuthStore } from "@/stores/authStore"; import { initializeEndpoint, isMultiRegionEnabled } from "@/lib/api/multi-region"; +import { startIdlePrefetch } from "@/lib/prefetch"; interface AuthProviderProps { children: React.ReactNode; @@ -21,6 +22,11 @@ export function AuthProvider({ children }: AuthProviderProps) { // Get auth state const { isAuthenticated, isGuest } = useAuthStore(); + // Start idle-time prefetch of heavy modules after initial render + useEffect(() => { + startIdlePrefetch(); + }, []); + // Initialize multi-region endpoint detection on app startup // Checks Gateway first, falls back to fastest region if Gateway is down // Note: initializeEndpoint() is designed to never throw, handles all errors internally diff --git a/frontend/src/lib/prefetch.ts b/frontend/src/lib/prefetch.ts new file mode 100644 index 00000000..e709f67b --- /dev/null +++ b/frontend/src/lib/prefetch.ts @@ -0,0 +1,51 @@ +/** + * Prefetch Module - Idle-time module preloading + * + * Uses requestIdleCallback to preload heavy modules during browser idle time. + * This ensures the modules are cached and ready when actually needed, + * without blocking initial page load. + */ + +import { logger } from "./logger-client"; + +// Track which modules have been prefetched +const prefetchedModules = new Set(); + +/** + * Prefetch a module during browser idle time + * @param moduleLoader - Dynamic import function + * @param name - Module name for logging + */ +function prefetchModule(moduleLoader: () => Promise, name: string): void { + if (prefetchedModules.has(name)) return; + prefetchedModules.add(name); + + const callback = () => { + moduleLoader() + .then(() => logger.debug(`Prefetched module: ${name}`)) + .catch(() => logger.debug(`Failed to prefetch: ${name}`)); + }; + + // Use requestIdleCallback if available, otherwise setTimeout + if ("requestIdleCallback" in window) { + requestIdleCallback(callback, { timeout: 5000 }); + } else { + setTimeout(callback, 2000); + } +} + +/** + * Start prefetching heavy modules after initial page load + * Called once after app mounts and becomes interactive + */ +export function startIdlePrefetch(): void { + // Wait a bit for initial render to complete + setTimeout(() => { + // Prefetch cache-manager (contains music-metadata ~100KB) + // Needed for: upload, library delete + prefetchModule( + () => import("@/lib/pwa/cache-manager"), + "cache-manager" + ); + }, 3000); // 3 seconds after load +} diff --git a/frontend/src/lib/services/library-service.ts b/frontend/src/lib/services/library-service.ts index 08121a59..dbb4fcb8 100644 --- a/frontend/src/lib/services/library-service.ts +++ b/frontend/src/lib/services/library-service.ts @@ -11,7 +11,12 @@ import { db } from "@/lib/db/schema"; import { logger } from "@/lib/logger-client"; -import { getCacheName } from "@/lib/pwa/cache-manager"; + +// Lazy-load cache-manager to reduce initial bundle size +async function getCacheNameLazy(type: "audio" | "covers"): Promise { + const { getCacheName } = await import("@/lib/pwa/cache-manager"); + return getCacheName(type); +} export interface DeleteProgress { stage: "librarySongs" | "playlistSongs" | "songs" | "cache" | "library" | "complete"; @@ -90,7 +95,7 @@ class LibraryService { message: "Deleting songs and cache", }); - const cache = await caches.open(getCacheName("audio")); + const cache = await caches.open(await getCacheNameLazy("audio")); let processed = 0; for (const song of songs) { @@ -224,7 +229,7 @@ class LibraryService { await db.files.delete(fileId); if (songStreamUrl) { - const cache = await caches.open(getCacheName("audio")); + const cache = await caches.open(await getCacheNameLazy("audio")); await cache.delete(songStreamUrl); logger.debug("Cache and file deleted", { songId, fileId }); } @@ -240,7 +245,7 @@ class LibraryService { } } else if (songStreamUrl) { // No fileId means unique file (old data), safe to delete - const cache = await caches.open(getCacheName("audio")); + const cache = await caches.open(await getCacheNameLazy("audio")); await cache.delete(songStreamUrl); logger.debug("Cache deleted (unique file)", { songId }); }