Repository: minht11/local-music-pwa Branch: main Commit: 23712cbccf40 Files: 230 Total size: 547.6 KB Directory structure: gitextract_z80t98b9/ ├── .env.example ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── AGENTS.md ├── LICENSE.txt ├── README.md ├── biome.jsonc ├── knip.json ├── lib/ │ ├── vite-image-metadata.ts │ └── vite-log-chunk-size.ts ├── messages/ │ ├── de.json │ ├── en.json │ ├── fr.json │ ├── lt.json │ ├── zh-CN.json │ └── zh-TW.json ├── netlify.toml ├── package.json ├── patches/ │ └── @material__material-color-utilities.patch ├── pnpm-workspace.yaml ├── project.inlang/ │ └── settings.json ├── scripts/ │ ├── check-translations.ts │ └── gen-color-theme.ts ├── src/ │ ├── ambient.d.ts │ ├── app.css │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ ├── lib/ │ │ ├── app-metadata.ts │ │ ├── attachments/ │ │ │ ├── ripple.ts │ │ │ └── tooltip.ts │ │ ├── components/ │ │ │ ├── AlbumsListContainer.svelte │ │ │ ├── ArtistListContainer.svelte │ │ │ ├── Artwork.svelte │ │ │ ├── BackButton.svelte │ │ │ ├── Button.svelte │ │ │ ├── FavoriteButton.svelte │ │ │ ├── Header.svelte │ │ │ ├── IconButton.svelte │ │ │ ├── ListDetailsLayout.svelte │ │ │ ├── ListItem.svelte │ │ │ ├── MenuButton.svelte │ │ │ ├── PlayerOverlay.svelte │ │ │ ├── ScrollContainer.svelte │ │ │ ├── Select.svelte │ │ │ ├── Separator.svelte │ │ │ ├── Slider.svelte │ │ │ ├── Spinner.svelte │ │ │ ├── Switch.svelte │ │ │ ├── Tabs.svelte │ │ │ ├── TextField.svelte │ │ │ ├── VirtualContainer.svelte │ │ │ ├── WrapTranslation.svelte │ │ │ ├── animated-icons/ │ │ │ │ ├── PlayPauseIcon.svelte │ │ │ │ └── PlayPreviousNextIcon.svelte │ │ │ ├── dialog/ │ │ │ │ ├── CommonDialog.svelte │ │ │ │ ├── Dialog.svelte │ │ │ │ └── DialogFooter.svelte │ │ │ ├── global-dialogs/ │ │ │ │ ├── EqualizerDialog.svelte │ │ │ │ ├── RemoveFromLibraryDialog.svelte │ │ │ │ ├── dialogs.ts │ │ │ │ └── playlists/ │ │ │ │ ├── AddToPlaylistDialog.svelte │ │ │ │ ├── AddToPlaylistDialogContent.svelte │ │ │ │ ├── EditPlaylistDialog.svelte │ │ │ │ └── NewPlaylistDialog.svelte │ │ │ ├── icon/ │ │ │ │ ├── Icon.svelte │ │ │ │ └── icon-paths.server.ts │ │ │ ├── library-grid/ │ │ │ │ ├── LibraryGridItem.svelte │ │ │ │ └── LibraryGridListContainer.svelte │ │ │ ├── menu/ │ │ │ │ ├── Menu.svelte │ │ │ │ ├── MenuRenderer.svelte │ │ │ │ ├── positioning.ts │ │ │ │ └── types.ts │ │ │ ├── player/ │ │ │ │ ├── MainControls.svelte │ │ │ │ ├── PlayerArtwork.svelte │ │ │ │ ├── Timeline.svelte │ │ │ │ ├── VolumeSlider.svelte │ │ │ │ └── buttons/ │ │ │ │ ├── ActiveIndicator.svelte │ │ │ │ ├── PlayNextButton.svelte │ │ │ │ ├── PlayPrevButton.svelte │ │ │ │ ├── PlayToggleButton.svelte │ │ │ │ ├── PlayTogglePillButton.svelte │ │ │ │ ├── PlayerFavoriteButton.svelte │ │ │ │ ├── RepeatButton.svelte │ │ │ │ └── ShuffleButton.svelte │ │ │ ├── playlists/ │ │ │ │ ├── PlaylistListContainer.svelte │ │ │ │ └── PlaylistListItem.svelte │ │ │ ├── snackbar/ │ │ │ │ ├── Snackbar.svelte │ │ │ │ ├── SnackbarRenderer.svelte │ │ │ │ ├── snackbar.ts │ │ │ │ └── store.svelte.ts │ │ │ └── tracks/ │ │ │ ├── TrackListItem.svelte │ │ │ ├── TracksListContainer.svelte │ │ │ ├── selection.svelte.ts │ │ │ ├── use-track-drag-controller.svelte.ts │ │ │ ├── use-track-menu-items.ts │ │ │ └── use-track-selection-controller.svelte.ts │ │ ├── db/ │ │ │ ├── database.ts │ │ │ ├── events.ts │ │ │ ├── lock-database.ts │ │ │ └── query/ │ │ │ ├── base-query.svelte.ts │ │ │ ├── inline-query.svelte.ts │ │ │ ├── page-query.svelte.ts │ │ │ └── query.ts │ │ ├── helpers/ │ │ │ ├── __tests__/ │ │ │ │ └── serial-queue.test.ts │ │ │ ├── animations.ts │ │ │ ├── audio.ts │ │ │ ├── create-managed-artwork.svelte.ts │ │ │ ├── debounced.svelte.ts │ │ │ ├── file-system.ts │ │ │ ├── focus.ts │ │ │ ├── input.ts │ │ │ ├── persist.svelte.ts │ │ │ ├── register-sw.ts │ │ │ ├── serial-queue.ts │ │ │ ├── test-helpers.ts │ │ │ ├── ui-action.ts │ │ │ ├── utils/ │ │ │ │ ├── array.ts │ │ │ │ ├── assign.ts │ │ │ │ ├── clamp.ts │ │ │ │ ├── debounce.ts │ │ │ │ ├── format-duration.ts │ │ │ │ ├── integers.ts │ │ │ │ ├── navigate.ts │ │ │ │ ├── text.ts │ │ │ │ ├── throttle.ts │ │ │ │ ├── ua.ts │ │ │ │ └── wait.ts │ │ │ └── virtualizer.svelte.ts │ │ ├── layout-bottom-bar.svelte.ts │ │ ├── library/ │ │ │ ├── __tests__/ │ │ │ │ ├── play-history.test.ts │ │ │ │ ├── playlists.test.ts │ │ │ │ └── remove.test.ts │ │ │ ├── get/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── value.test.ts │ │ │ │ ├── ids-queries.ts │ │ │ │ ├── ids.ts │ │ │ │ ├── value-queries.ts │ │ │ │ └── value.ts │ │ │ ├── play-history-actions.ts │ │ │ ├── playlists-actions.ts │ │ │ ├── remove.ts │ │ │ ├── scan-actions/ │ │ │ │ ├── directories.ts │ │ │ │ ├── scan-tracks.ts │ │ │ │ └── scanner/ │ │ │ │ ├── actions.ts │ │ │ │ ├── import-track.ts │ │ │ │ ├── parse/ │ │ │ │ │ ├── format-artwork.ts │ │ │ │ │ ├── image-primary-color.ts │ │ │ │ │ └── parse-track.ts │ │ │ │ ├── start.ts │ │ │ │ ├── types.ts │ │ │ │ └── worker.ts │ │ │ ├── tracks-queries.ts │ │ │ └── types.ts │ │ ├── menu-actions/ │ │ │ └── playlists.ts │ │ ├── stores/ │ │ │ ├── dialogs/ │ │ │ │ ├── store.svelte.ts │ │ │ │ └── use-store.ts │ │ │ ├── main/ │ │ │ │ ├── store.svelte.ts │ │ │ │ └── use-store.ts │ │ │ └── player/ │ │ │ ├── __test__/ │ │ │ │ ├── audio-loader.test.ts │ │ │ │ ├── equalizer.test.ts │ │ │ │ ├── player.svelte.test.ts │ │ │ │ └── queue.test.ts │ │ │ ├── audio-loader.svelte.ts │ │ │ ├── equalizer.svelte.ts │ │ │ ├── player.svelte.ts │ │ │ ├── queue.svelte.ts │ │ │ └── use-store.ts │ │ ├── theme.ts │ │ └── view-transitions.svelte.ts │ ├── params/ │ │ └── libraryEntities.ts │ ├── routes/ │ │ ├── (app)/ │ │ │ ├── (plain)/ │ │ │ │ ├── +layout.svelte │ │ │ │ ├── about/ │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── +page.ts │ │ │ │ └── settings/ │ │ │ │ ├── +page.svelte │ │ │ │ ├── +page.ts │ │ │ │ └── components/ │ │ │ │ ├── DirectoriesList.svelte │ │ │ │ ├── InstallAppBanner.svelte │ │ │ │ └── MissingFsApiBanner.svelte │ │ │ ├── +layout.svelte │ │ │ ├── layout/ │ │ │ │ ├── app-install-prompt.ts │ │ │ │ ├── setup-directories-permission-prompt.svelte.ts │ │ │ │ └── setup-theme.svelte.ts │ │ │ ├── library/ │ │ │ │ └── [[slug=libraryEntities]]/ │ │ │ │ ├── +layout.svelte │ │ │ │ ├── +layout.ts │ │ │ │ ├── +page.svelte │ │ │ │ ├── Search.svelte │ │ │ │ ├── [uuid]/ │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── +page.ts │ │ │ │ ├── config.ts │ │ │ │ └── store.svelte.ts │ │ │ └── player/ │ │ │ ├── +layout.svelte │ │ │ ├── +layout.ts │ │ │ ├── +page.ts │ │ │ ├── history/ │ │ │ │ └── +page.ts │ │ │ ├── layout-props.ts │ │ │ └── queue/ │ │ │ └── +page.ts │ │ ├── (assets)/ │ │ │ ├── icons/ │ │ │ │ └── icon.server.ts │ │ │ └── manifest.webmanifest/ │ │ │ └── +server.ts │ │ ├── (marketing)/ │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── AGENTS.md │ │ │ ├── TONE_OF_VOICE.md │ │ │ ├── assets/ │ │ │ │ ├── hero.avif │ │ │ │ └── marketing-equalizer-preview.avif │ │ │ └── components/ │ │ │ ├── FeaturesSection.svelte │ │ │ ├── GettingStartedSection.svelte │ │ │ ├── HeroSection.svelte │ │ │ ├── HowItWorksSection.svelte │ │ │ ├── Section.svelte │ │ │ └── SoundControlsSection.svelte │ │ ├── +error.svelte │ │ ├── +layout.svelte │ │ └── +layout.ts │ ├── server/ │ │ └── theme-colors.ts │ ├── service-worker.ts │ └── theme-colors.css ├── static/ │ └── supported-browser-check.js ├── svelte.config.js ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .env.example ================================================ PUBLIC_FALLBACK_PAGE=/200.html PUBLIC_GOAT_COUNTER_URL= ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- ## ⚠️ Before creating this issue **Please check if a similar issue already exists:** - [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this bug has not been reported before ## 🐛 Bug Description A clear and concise description of what the bug is. ## 🔄 Steps to Reproduce 1. Go to '...' 2. Click on '...' 3. Scroll down to '...' 4. See error ## ✅ Expected Behavior A clear and concise description of what you expected to happen. ## ❌ Actual Behavior A clear and concise description of what actually happened. ## 📱 Device Information **Device Type:** (e.g., Desktop, Mobile, Tablet) **Operating System:** (e.g., Windows 11, macOS 14, iOS 17, Android 13) **Browser:** (e.g., Chrome 120, Firefox 121, Safari 17) **Browser Version:** **Screen Resolution:** (if relevant) ## 📸 Screenshots/Videos If applicable, add screenshots or videos to help explain your problem. ## 🎵 Music Library Details (if relevant) **Library Size:** (approximate number of songs/albums) **File Formats:** (e.g., MP3, FLAC, AAC) ## 🔧 Additional Context Add any other context about the problem here. Include: - Console errors (if any) - Network connectivity issues - Any recent changes to your music library - Whether this happens consistently or intermittently ## 📋 Browser Console Errors If there are any console errors, please include them here: ``` Paste console errors here ``` ## 🔍 Additional Information - Does this issue occur in incognito/private browsing mode? - Have you tried clearing browser cache/data? - Does this issue occur on other devices/browsers? ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: ❓ Questions & Support url: https://github.com/minht11/local-music-pwa/discussions about: Ask questions, get help, or discuss how to use the app - name: 💬 General Discussions url: https://github.com/minht11/local-music-pwa/discussions about: Share ideas, feedback, or chat with the community ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- ## ⚠️ Before creating this issue **Please check if a similar feature has already been requested:** - [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this feature has not been requested before ## 💡 What feature would you like to see? A clear description of the feature you'd like to see implemented. ## 🎯 Why do you need this feature? What problem would this solve or what would this help you do? ## 🎨 Screenshots/Examples (Optional) If you have examples from other apps or mockups, add them here. ## 📋 Additional Context Any other details that might be helpful. ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: push: branches: - main env: PUBLIC_FALLBACK_PAGE: ${{ vars.PUBLIC_FALLBACK_PAGE }} PUBLIC_GOAT_COUNTER_URL: ${{ vars.PUBLIC_GOAT_COUNTER_URL }} jobs: code-quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: pnpm/action-setup@v6.0.4 with: run_install: false - uses: actions/setup-node@v6.4.0 with: node-version-file: "package.json" cache: "pnpm" - run: pnpm install - name: Build generated files run: pnpm run build - run: pnpm run biome-check - run: pnpm run prettier-check - run: pnpm run type-check - run: pnpm test - run: pnpm run knip ================================================ FILE: .gitignore ================================================ .DS_Store node_modules build .generated .env !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* coverage ================================================ FILE: .prettierignore ================================================ .DS_Store node_modules /build/ /.generated/ /package .env .env.* !.env.example # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml package-lock.json yarn.lock # Let Biome handle these files *.ts *.tsx *.js *.json *.jsonc *.css *.html ================================================ FILE: .prettierrc ================================================ { "useTabs": true, "singleQuote": true, "trailingComma": "all", "semi": false, "printWidth": 100, "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "tailwindStylesheet": "./src/app.css", "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome", "svelte.svelte-vscode"] } ================================================ FILE: .vscode/settings.json ================================================ { "svelte.plugin.svelte.compilerWarnings": { "missing-declaration": "ignore" }, "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[css]": { "editor.defaultFormatter": "biomejs.biome" }, "[html]": { "editor.defaultFormatter": "biomejs.biome" }, "editor.formatOnSave": true, "eslint.useESLintClass": true, "npm.packageManager": "pnpm", "js/ts.tsdk.path": "node_modules/typescript/lib", "files.associations": { "*.css": "tailwindcss" }, "css.lint.unknownAtRules": "ignore" } ================================================ FILE: AGENTS.md ================================================ # Agent instructions ## Project Overview **Snae Player** is a privacy-first local music PWA that runs entirely in the browser. Built with **SvelteKit 5**, **TypeScript**, and **Tailwind CSS 4**, it emphasizes performance, type safety, and maintainability. ### Core Features - Local music playback using File System Access API or Files API fallback - Privacy-preserving (no data sent to servers) - IndexedDB for local storage and metadata - Web Workers for performance-intensive operations - Progressive Web App with offline support ## Technology Stack ### Frontend Framework - **SvelteKit 5** with Svelte Runes (`$state`, `$derived`, `$effect`) - **TypeScript** strict mode with no `any` types - **Tailwind CSS 4** with custom design system in `src/app.css` - **Vite 8** with Rolldown bundler ### Core Dependencies ```json { "dependencies": { "@material/material-color-utilities": "^0.4.0", "@tanstack/virtual-core": "^3.13.23", "idb": "^8.0.3", "music-metadata": "^11.12.3", "tiny-invariant": "^1.3.3", "weak-lru-cache": "^1.2.2" } } ``` ### Development Tools - **pnpm** for package management - **Biome** for linting (primary) - **Prettier** for Svelte formatting - **Vitest** for testing with `fake-indexeddb` - **unplugin-auto-import** for global utilities - **@inlang/paraglide-js** for i18n (compiled to `.generated/paraglide/`) ## File Organization ``` src/ ├── routes/ │ ├── (app)/ # Main application routes (with bottom bar) │ │ ├── library/ # Music library with slug-based entity views │ │ ├── player/ # Full-screen audio player (queue, history) │ │ ├── layout/ # Layout-level setup (install prompt, theme) │ │ └── (plain)/ # Routes without bottom nav bar │ │ ├── settings/ # App settings │ │ └── about/ # About page │ ├── (marketing)/ # Landing page │ └── (assets)/ # Dynamic asset routes ├── lib/ │ ├── components/ # Reusable UI components │ │ ├── icon/ # SVG icon system │ │ ├── tracks/ # Track list components │ │ ├── playlists/ # Playlist components │ │ ├── player/ # Player UI components │ │ ├── menu/ # Context menu system │ │ ├── dialog/ # Modal dialogs │ │ ├── snackbar/ # Toast notifications │ │ ├── app-dialogs/ # App-level dialogs │ │ ├── library-grid/ # Library grid layout │ │ └── animated-icons/ # Animated icon components │ ├── stores/ # Global state management │ │ ├── main/ # App settings, theme (MainStore) │ │ ├── player/ # Audio playback state (PlayerStore) │ │ └── dialogs/ # Dialog state (DialogsStore) │ ├── db/ # IndexedDB operations │ │ ├── query/ # Reactive database queries │ │ ├── database.ts # DB schema & connection │ │ └── events.ts # DB change events │ ├── library/ # Music library operations │ │ ├── scan-actions/ # File scanning and parsing │ │ ├── get/ # Query helpers (ids, values) │ │ ├── playlists-actions.ts │ │ ├── play-history-actions.ts │ │ ├── tracks-queries.ts │ │ └── types.ts │ ├── helpers/ # Utility functions │ └── attachments/ # Svelte element attachments (ripple, tooltip) tests/ ├── lib/ │ └── library/ # Library functionality tests └── shared.ts # Test utilities (clearDatabaseStores) ``` ## Design System & Styling ### Design Tokens Use design tokens from `src/app.css` and `src/theme-colors.css` — **never arbitrary values**. Prefer theme breakpoint variables in media queries, for example `@media (width >= --theme(--breakpoint-sm))`. Use a custom breakpoint only when there is no matching theme breakpoint for the behavior you need. In Svelte component ` ``` ### Key Component Library Available in `src/lib/components/`: **Basic UI:** - `Button.svelte` - Primary/secondary buttons - `IconButton.svelte` - Icon-only buttons - `MenuButton.svelte` - Button that opens a context menu - `Icon.svelte` - SVG icon system - `TextField.svelte` - Text input fields - `Select.svelte` - Dropdown selects - `Switch.svelte` - Toggle switches - `Slider.svelte` - Range slider - `Tabs.svelte` - Tab navigation - `Spinner.svelte` - Loading indicator - `FavoriteButton.svelte` - Toggle favorite state **Layout:** - `Header.svelte` - Page headers - `BackButton.svelte` - Navigation back button - `Separator.svelte` - Visual dividers - `ScrollContainer.svelte` - Scrollable container - `VirtualContainer.svelte` - Virtual scrolling for large lists - `ListDetailsLayout.svelte` - Master-detail layout - `ListItem.svelte` - Generic list item **Music-specific:** - `Artwork.svelte` - Album/track artwork - `PlayerOverlay.svelte` - Mini player overlay - `TracksListContainer.svelte` - Virtual track lists (`src/lib/components/tracks/`) - `PlaylistListContainer.svelte` - Playlist list (`src/lib/components/playlists/`) - `AlbumsListContainer.svelte` - Albums grid/list - `ArtistListContainer.svelte` - Artists list ## State Management ### Store Architecture Uses context-based stores with Svelte 5 runes: ```typescript // Main application store const mainStore = useMainStore() mainStore.theme // AppThemeOption: 'light' | 'dark' | 'auto' mainStore.isThemeDark // boolean (derived) mainStore.motion // AppMotionOption: 'normal' | 'reduced' | 'auto' mainStore.isReducedMotion // boolean (derived) mainStore.pickColorFromArtwork // boolean mainStore.volumeSliderEnabled // boolean mainStore.librarySplitLayoutEnabled // boolean // Audio player store const player = usePlayer() player.playing // boolean (true = playing) player.loading // boolean (true = loading audio) player.activeTrack // TrackData | undefined player.itemsIds // readonly number[] (queue track IDs) player.currentTime // number (seconds) player.duration // number (seconds) player.volume // number (0–100) player.muted // boolean player.shuffle // boolean player.repeat // PlayerRepeat: 'none' | 'one' | 'all' player.equalizer // EqualizerStore player.artworkSrc // string | undefined // Player actions player.playTrack(trackIds, options?) // Set queue and play player.togglePlay(force?) // Toggle or force play/pause player.playNext() // Next track player.playPrev() // Previous track player.seek(time) // Seek to time in seconds player.toggleRepeat() // Cycle repeat mode player.toggleShuffle() // Toggle shuffle player.addToQueue(trackId) // Add track(s) to queue player.removeFromQueue(index) // Remove by queue index player.clearQueue() // Empty the queue ``` ### Persistence Stores self-persist via the `persist()` helper (used internally in store constructors — do not call it for new ad-hoc values): ```typescript // Inside a store class constructor persist('storeName', this, ['fieldA', 'fieldB']) // Keys are persisted to localStorage under snaeplayer-{storeName}.{key} ``` ## Database Layer ### Architecture - **IndexedDB** via `idb` library - **Reactive queries** that auto-update UI components - **Type-safe** operations - **Migration system** for schema changes ### Database Schema ```typescript // From $lib/library/types.ts interface Track { id: number uuid: string name: string artists: StringOrUnknownItem[] album: StringOrUnknownItem year: StringOrUnknownItem duration: number genre: string[] trackNo: number trackOf: number discNo: number discOf: number fileName: string directory: number // FK to Directory.id; -1 = legacy no-native-directory scannedAt: number file: FileEntity image?: { optimized: boolean; small: Blob; full: Blob } primaryColor?: number } interface Album { id: number uuid: string name: string artists: string[] year?: string image?: Blob } interface Artist { id: number uuid: string name: string } interface Playlist { id: number uuid: string name: string description: string createdAt: number } interface PlaylistEntry { id: number playlistId: number trackId: number addedAt: number } interface PlayHistoryEntry { id: number trackId: number playedAt: number } interface Directory { id: number handle: FileSystemDirectoryHandle } ``` Stores: `tracks`, `albums`, `artists`, `playlists`, `playlistEntries`, `directories`, `playHistory` Special constants from `$lib/library/types.ts`: - `FAVORITE_PLAYLIST_ID = -1` — built-in favorites playlist (not user-modifiable) - `UNKNOWN_ITEM = '~\0unknown'` — sentinel for unknown artist/album/year - `LEGACY_NO_NATIVE_DIRECTORY = -1` — for tracks without a directory handle ### Database Operations ```typescript // Basic operations import { getDatabase } from '$lib/db/database.ts' const db = await getDatabase() const tracks = await db.getAll('tracks') const track = await db.get('tracks', trackId) // Reactive queries import { createPageQuery } from '$lib/db/query/page-query.svelte.ts' const tracksQuery = createPageQuery({ queryFn: async () => { const db = await getDatabase() return await db.getAll('tracks') }, onDatabaseChange: (changes) => { // Auto-refetch when tracks change return changes.some((c) => c.storeName === 'tracks') }, }) ``` ## Music Library Operations ### File Scanning ```typescript // Scanner architecture import { scanTracks } from '$lib/library/scan-actions/scan-tracks.ts' // Scan new files await scanTracks({ action: 'scan-new-directory', files: fileEntities, }) ``` ### Playlist Management ```typescript import { dbCreatePlaylist, dbAddTracksToPlaylist, dbRemoveTracksFromPlaylist, toggleFavoriteTrack, } from '$lib/library/playlists-actions.ts' // Create playlist const playlistId = await dbCreatePlaylist('My Playlist', 'Description') // Add tracks await dbAddTracksToPlaylist(playlistId, trackIds) // Favorites await toggleFavoriteTrack(trackId) // Adds/removes from favorites ``` ## Testing Guidelines ### Test Structure ```typescript import { describe, it, expect, vi, afterEach } from 'vitest' import { clearDatabaseStores } from '../../shared.ts' describe('component functionality', () => { afterEach(async () => { await clearDatabaseStores() vi.clearAllMocks() }) it('should handle user interaction correctly', async () => { // Test implementation }) }) ``` ## Error Handling ### Runtime Assertions ```typescript import { invariant } from 'tiny-invariant' // Auto-imported // Use for critical runtime checks invariant(track, 'Track must be defined') invariant(tracks.length > 0, 'Must have tracks to play') ``` ### Error Boundaries ```svelte

Something went wrong

{error.message}

``` ### Graceful Degradation ```typescript // Feature detection if ('showDirectoryPicker' in window) { // Use File System Access API } else { // Fallback to File API } ``` ## Development Workflow ### Commands ```bash # Development pnpm run dev # Start dev server # Building pnpm run build # Production build pnpm run preview # Preview build # Code Quality pnpm run i18n-check # Validate translations in messages/*.json pnpm run type-check # Type checking pnpm run biome-check # Linting pnpm run biome-fix # Fix linting issues # Testing pnpm run test # Run tests ``` ### Code Quality Rules #### Always Do ✅ - Use pnpm when running commands - Leverage auto-imports for common utilities - Use design system tokens, never arbitrary values - Apply `.interactable` class to all clickable elements - Leverage auto-imported utilities (don't import them) - Use Svelte 5 runes for reactive state - Type everything explicitly - avoid `any` types - Handle loading and error states - Include accessibility attributes - Use `invariant()` for runtime checks - Clear test mocks in `afterEach` - Run `pnpm run i18n-check` after adding/changing i18n keys - Keep i18n placeholders exactly aligned with English keys (`{count}`, `{name}`, etc.) #### Never Do ❌ - Use arbitrary Tailwind classes for colors/spacing - Import auto-imported utilities (m, usePlayer, useMainStore, etc.) - Skip TypeScript strict mode checks - Ignore accessibility requirements - Add server-side dependencies except in `+server.ts` files - Use `any` types except for complex generics - Skip error handling - Hardcode strings (use i18n messages) ### File Naming Conventions - **Components**: `PascalCase.svelte` - **Routes**: `+page.svelte`, `+layout.svelte`, `+page.ts` - **Types**: `kebab-case.ts` - **Stores**: `kebab-case.svelte.ts` ## Key Files Reference ### Configuration - `vite.config.ts` - Build and auto-import configuration - `svelte.config.js` - SvelteKit configuration - `biome.jsonc` - Code quality rules ### Core Application - `src/app.css` - Design system and global styles - `src/theme-colors.css` - Color design tokens (camelCase names) - `src/app.d.ts` - Global TypeScript definitions - `src/app.html` - HTML template - `src/lib/stores/` - Global state management - `src/lib/db/database.ts` - IndexedDB setup ## Marketing Copy For landing-page edits under `src/routes/(marketing)/`, follow the colocated guidance in `src/routes/(marketing)/AGENTS.md` and `src/routes/(marketing)/TONE_OF_VOICE.md`. ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2019 Justinas Delinda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Snae Player **[snaeplayer.com](https://snaeplayer.com)** - Local music player in the browser. Play audio files stored on your device. Includes playlists, queue, favorites, equalizer, playback speed, and artwork-based theming.

Snae Player showing the music library and playback controls

## Browser support Works in all modern browsers. When the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) is available, currently Chromium-based browsers, the app reads files directly from your chosen folder. In other browsers, files are copied into IndexedDB, which doubles the storage used. ## Privacy Your music files and library data stay on your device. The app does not collect or transmit them. Page views are counted using [GoatCounter](https://goatcounter.com/), a minimal privacy-preserving analytics tool. ## Tech stack SvelteKit/Svelte 5 · TypeScript · Tailwind CSS 4 ## Building locally Clone the repo, then: ``` pnpm install pnpm run build ``` Or run the development server: ``` pnpm run dev ``` ================================================ FILE: biome.jsonc ================================================ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": true, "clientKind": "git", "defaultBranch": "main", "useIgnoreFile": true }, "assist": { "actions": { "source": { "organizeImports": { "level": "on", "options": { "groups": [ ":URL:", ":NODE:", ":PACKAGE:", ":PACKAGE_WITH_PROTOCOL:", "$app/**", "$server/**", "$lib/**", ":ALIAS:", ":PATH:" ] } } } } }, "files": { "includes": [ "**", "!**/.generated", // Biome parser incorrectly errors on TS syntax. "!src/lib/components/VirtualContainer.svelte", "!static/supported-browser-check.js" ] }, "formatter": { "enabled": true, "lineWidth": 100, "includes": ["**", "!**/*.svelte", "**/*.svelte.ts"] }, "linter": { "enabled": true, "domains": { "project": "recommended" }, "rules": { "style": { "noNegationElse": "error", "useBlockStatements": "error", "useCollapsedElseIf": "error", "useConsistentArrayType": { "level": "error", "options": { "syntax": "shorthand" } }, "useShorthandAssign": "error", "useFilenamingConvention": { "level": "error", "options": { "requireAscii": true, "filenameCases": ["kebab-case", "export", "PascalCase"] } }, "useThrowNewError": "error", "useThrowOnlyError": "error", "useConsistentBuiltinInstantiation": "error", "useLiteralEnumMembers": "error", "useNodejsImportProtocol": "error", "useAsConstAssertion": "error", "useEnumInitializers": "error", "useSelfClosingElements": "error", "useSingleVarDeclarator": "error", "noUnusedTemplateLiteral": "error", "useNumberNamespace": "error", "noInferrableTypes": "error", "useExponentiationOperator": "error", "useTemplate": "error", "noParameterAssign": "error", "noNonNullAssertion": "error", "useDefaultParameterLast": "error", "useExportType": "error", "noUselessElse": "error", "useShorthandFunctionType": "error", "useNumericSeparators": "error", "noSubstr": "error", "useTrimStartEnd": "error", "useObjectSpread": "error", "useGroupedAccessorPairs": "error", "useForOf": "error", "useDeprecatedReason": "error", "useAtIndex": "error", "noYodaExpression": "error", "useConsistentArrowReturn": "error", "useArrayLiterals": "error", "useCollapsedIf": "error", "useConsistentTypeDefinitions": "error", "useExplicitLengthCheck": "error", "noShoutyConstants": "error", "noRestrictedImports": { "level": "error", "options": { "paths": { "@material/material-color-utilities": "Should not be used directly except in specific theme entrypoints to avoid breaking making big chunks" } } }, "useConsistentObjectDefinitions": "error" }, "correctness": { "noUndeclaredVariables": "error", "useImportExtensions": "error", "noPrivateImports": { "level": "error", "options": { "defaultVisibility": "package" } }, "useSingleJsDocAsterisk": "error", "noUnknownFunction": { "level": "on", "options": { "ignore": ["theme"] } } }, "nursery": { "noFloatingPromises": "error", "noMisusedPromises": "error", "noNestedPromises": "error", "noIncrementDecrement": "error", "noUnnecessaryConditions": "error", "noUselessReturn": "error", "useConsistentMethodSignatures": "error" }, "complexity": { "useSimplifiedLogicExpression": "error", "useNumericLiterals": "error", "noCommaOperator": "error", "noImportantStyles": "off" }, "suspicious": { "useAwait": "error", "useErrorMessage": "error", "noConsole": { "level": "error", "options": { "allow": ["assert", "error", "info", "warn", "time", "timeEnd", "debug"] } }, "noUnknownAtRules": { "level": "info", "options": { "ignore": ["slot"] } }, "noImportCycles": "error", "noDeprecatedImports": "on" }, "a11y": { "noSvgWithoutTitle": "off" }, "performance": { "useTopLevelRegex": "error" } } }, "javascript": { "formatter": { "semicolons": "asNeeded", "quoteStyle": "single", "jsxQuoteStyle": "single", "indentWidth": 4 }, "globals": [ "m", "invariant", "usePlayer", "useMainStore", "useDialogsStore", "useMenu", "untrack", "snackbar" ] }, "json": { "formatter": { "indentWidth": 2 } }, "css": { "parser": { "tailwindDirectives": true }, "formatter": { "enabled": true, "quoteStyle": "single" } }, "html": { "experimentalFullSupportEnabled": true, "formatter": { "enabled": true } }, "overrides": [ { "includes": ["**/*.svelte", "!**/*.svelte.ts"], "linter": { "rules": { "correctness": { "noUnusedVariables": "off", "noUnusedFunctionParameters": "off" }, "complexity": { "noCommaOperator": "off" } } } }, { "includes": ["**/src/params/**"], "linter": { "rules": { "style": { "useFilenamingConvention": "off" } } } }, { "includes": ["**/*.test.ts"], "linter": { "rules": { "correctness": { "noPrivateImports": { "level": "error", "options": { "defaultVisibility": "public" } } } } } }, { "includes": ["messages/*.json", "src/lib/components/icon/icon-paths.server.ts"], "assist": { "actions": { "source": { "useSortedKeys": { "level": "on", "options": {} } } } } } ] } ================================================ FILE: knip.json ================================================ { "$schema": "https://unpkg.com/knip@6/schema.json", "tags": ["-lintignore"], "entry": [ "src/routes/**/*.{svelte,ts}", "src/lib/stores/**/*", ".generated/types/auto-imports.d.ts" ], "project": ["**/*", "!src/paraglide/**/*", "!static/supported-browser-check.js"], "ignoreExportsUsedInFile": { "interface": true, "type": true }, "ignoreUnresolved": ["^\\./\\$types\\.ts$"] } ================================================ FILE: lib/vite-image-metadata.ts ================================================ import fs from 'node:fs' import path from 'node:path' import { imageSizeFromFile } from 'image-size/fromFile' import type { Plugin } from 'vite' const imageQuery = '?as=metadata' const allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'] const queryRegex = /\?as=metadata$/ /** @public */ export function imageMetadataPlugin(): Plugin { return { name: 'vite-plugin-image-metadata', enforce: 'pre', load: { filter: { id: queryRegex, }, async handler(id) { const filePath = id.replace(imageQuery, '') const ext = path.extname(filePath).slice(1) if (!allowedExts.includes(ext)) { return } if (!fs.existsSync(filePath)) { return } const dimensions = await imageSizeFromFile(filePath) return ` import src from "${filePath}?url"; export const width = ${dimensions.width}; export const height = ${dimensions.height}; export { src }; export default { src, width, height }; ` }, }, } } ================================================ FILE: lib/vite-log-chunk-size.ts ================================================ import { readdirSync, statSync } from 'node:fs' import path from 'node:path' import type { Plugin } from 'vite' /** @public */ export const logChunkSizePlugin = (): Plugin => ({ name: 'vite-plugin-log-chunk-size', apply: 'build', enforce: 'post', writeBundle() { if (this.environment.name === 'ssr') { return } const dirSize = async (directory: string) => { const jsInfo = { size: 0, count: 0 } const totalInfo = { size: 0, count: 0 } const processDirectory = async (dir: string) => { const files = readdirSync(dir) for (const file of files) { const filePath = path.join(dir, file) const stat = statSync(filePath) if (stat.isDirectory()) { await processDirectory(filePath) } else { if (file.endsWith('.js')) { jsInfo.size += stat.size jsInfo.count += 1 } totalInfo.size += stat.size totalInfo.count += 1 } } } await processDirectory(directory) return { jsInfo, totalInfo } } setTimeout(async () => { const { jsInfo, totalInfo } = await dirSize('./build/_app/immutable') console.info('Size of JS chunks:', jsInfo.size / 1024, 'KB. Files count:', jsInfo.count) console.info( 'Size of all files:', totalInfo.size / 1024, 'KB. Files count:', totalInfo.count, ) }, 2000) }, }) ================================================ FILE: messages/de.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "Über", "aboutHomepage": "Webseite", "aboutJoinDiscord": "Discord", "aboutPrivacy": "Privatsphäre", "aboutSourceCode": "Quellcode", "album": "Album", "albums": "Alben", "appName": "Snae Player", "appNameShort": "Snae", "appUpdateAvailable": "Neue Version verfügbar", "artist": "Künstler", "artists": "Künstler", "cancel": "Abbrechen", "created": "Erstellt", "description": "Beschreibung", "directoryIsIncludedInParent": "\"{newDir}\" ist ein Unterverzeichnis von \"{existingDir}\" das bereits in Ihrer Bibliothek vorhanden ist. Eine erneute Hinzufügung ist nicht erforderlich.", "dismiss": "Schließen", "duration": "Dauer", "equalizerClose": "Schließen", "equalizerOpenEqualizer": "Equalizer öffnen", "equalizerPresetAcoustic": "Akustisch", "equalizerPresetBassBoost": "Bassverstärkung", "equalizerPresetClassical": "Klassik", "equalizerPresetElectronic": "Elektronisch", "equalizerPresetFlat": "Neutral", "equalizerPresetJazz": "Jazz", "equalizerPresetPop": "Pop", "equalizerPresetRock": "Rock", "equalizerPresetTrebleBoost": "Höhenverstärkung", "equalizerReset": "Zurücksetzen", "equalizerStatusEnabled": "Aktiviert", "equalizerTitle": "Equalizer", "errorPageDoesNotExist": "Diese Seite scheint nicht zu existieren.", "errorUnexpected": "Es ist ein unerwarteter Fehler aufgetreten.", "favorites": "Favoriten", "foundAnIssue": "Haben Sie einen Fehler entdeckt?", "goBack": "Zurück", "goHome": "Zur Startseite", "library": "Bibliothek", "libraryAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "libraryApplicationMenu": "App-Menü", "libraryCancel": "Abbrechen", "libraryConfirmRemoveMultipleTitle": "Sind Sie sicher, dass Sie diese {count} Elemente entfernen möchten?", "libraryConfirmRemoveTitle": "Sind Sie sicher, dass Sie \"{name}\" entfernen möchten?", "libraryCreate": "Erstellen", "libraryCreateNewPlaylist": "Neue Playlist", "libraryDirPromptBrowserPermission": "Browserberechtigung erforderlich", "libraryDirPromptExplanation": "Zum Abspielen von Musik benötigt die App Zugriff auf folgende Verzeichnisse:", "libraryDirPromptGrant": "Zulassen", "libraryEditPlaylist": "Wiedergabeliste bearbeiten", "libraryEditPlaylistName": "Wiedergabelistenname bearbeiten", "libraryEmpty": "Ihre Bibliothek ist leer", "libraryImportTracks": "Titel importieren", "libraryItemRemovedFromLibrary": "Element aus der Bibliothek entfernt", "libraryItemsRemovedFromLibrary": "Elemente aus der Bibliothek entfernt", "libraryNewPlaylist": "Neue Wiedergabeliste", "libraryNoResults": "Keine Ergebnisse gefunden", "libraryNoResultsExplanation": "Versuchen Sie es mit einer anderen Suche", "libraryOpenApplicationMenu": "Verwaltung", "libraryOpenSortMenu": "Sortieren nach", "libraryPlaylistCreated": "Wiedergabeliste \"{playlistName}\" erstellt", "libraryPlaylistFieldName": "Wiedergabelistenname", "libraryPlaylistName": "Wiedergabelistenname", "libraryPlaylistRemoved": "Wiedergabeliste entfernt", "libraryPlaylistsUpdated": "Wiedergabelisten aktualisiert", "libraryPlaylistUpdated": "Wiedergabelisten aktualisiert", "libraryRemove": "Entfernen", "libraryRemoveFromLibrary": "Aus der Bibliothek entfernen", "librarySave": "Speichern", "librarySearch": "Suchen", "librarySelectSomethingToBeShown": "Bitte wählen Sie einen Listeneintrag aus, der hier angezeigt werden soll", "librarySplitViewDisable": "Geteilte Ansicht deaktivieren", "librarySplitViewEnable": "Geteilte Ansicht aktivieren", "libraryStartByAdding": "Fügen Sie jetzt Musik hinzu", "libraryToggleSortOrder": "Sortierreihenfolge ändern", "libraryTrackRemovedFromPlaylist": "Titel aus der Wiedergabeliste entfernt", "libraryTrackRemoveFromPlaylist": "Aus Playlist entfernen", "libraryTracksCount": "{count} Titel", "libraryViewDetails": "Details anzeigen", "more": "Mehr", "moreOptions": "Weitere Optionen", "name": "Name", "noItemsToDisplay": "Keine Elemente vorhanden", "pause": "Pause", "play": "Abspielen", "player": "Player", "playerAddToQueue": "Zur Warteschlange hinzufügen", "playerAudioErrorLoadError": "Audio für \"{name}\" konnte nicht geladen werden", "playerAudioErrorNotFound": "Audiodatei für \"{name}\" nicht gefunden. Sie wurde möglicherweise verschoben oder gelöscht.", "playerAudioErrorPermissionDenied": "Keine Berechtigung, Audio für \"{name}\" zu laden. Bitte erteilen Sie die Browser-Berechtigung und versuchen Sie es erneut.", "playerClearHistory": "Verlauf löschen", "playerClearQueue": "Warteschlange löschen", "playerDecreaseVolume": "Lautstärke verringern", "playerDisableRepeat": "Wiederholung deaktivieren", "playerDisableShuffle": "Zufallswiedergabe deaktivieren", "playerEnableRepeat": "Wiederholung aktivieren", "playerEnableRepeatOne": "Einzelwiederholung aktivieren", "playerEnableShuffle": "Zufallswiedergabe aktivieren", "playerHistory": "Verlauf", "playerHistoryEmpty": "Ihr Wiedergabeverlauf ist leer", "playerIncreaseVolume": "Lautstärke erhöhen", "playerOpenFullPlayer": "Vollansicht öffnen", "playerOpenHistory": "Verlauf öffnen", "playerOpenQueue": "Warteschlange öffnen", "playerPause": "Pause", "playerPlay": "Abspielen", "playerPlayNextTrack": "Nächsten Titel abspielen", "playerPlayPreviousTrack": "Vorherigen Titel abspielen", "playerQueueEmpty": "Ihre Warteschlange ist leer", "playerQueuePlaySomething": "Musik entdecken", "playerRemoveFromHistory": "Aus Verlauf entfernen", "playerRemoveFromQueue": "Aus der Warteschlange entfernen", "playlist": "Wiedergabeliste", "playlists": "Wiedergabelisten", "queue": "Warteschlange", "reload": "Aktualisieren", "replace": "Ersetzen", "replaceDirectoryExplanation": "{newDir} ist ein übergeordnetes Verzeichnis von {existingDirs} und bereits in Ihrer Bibliothek enthalten.\n Bestehende Titel in Ihrer Bibliothek bleiben unverändert.", "replaceDirectoryQ": "Ordner ersetzen?", "selectAll": "Alle auswählen", "selectedCount": "{count} ausgewählt", "settingPickColorFromArtwork": "Farbanpassung basierend auf dem aktuellen Titel", "settings": "Einstellungen", "settingsAbout": "Über", "settingsAddDirectory": "Ordner hinzufügen", "settingsAllDataLocal": "Alle Daten bleiben lokal auf Ihrem Gerät", "settingsAppearance": "Oberflächendesign", "settingsApplicationTheme": "Erscheinungsbild", "settingsColorPick": "Farbwahl", "settingsColorReset": "Zurücksetzen", "settingsDbOperationInProgress": "Datenbankvorgang läuft ...", "settingsDirectories": "Ordner", "settingsDirectoriesTracksCount": "{count} Titel", "settingsDirectoryRemoved": "Ordner entfernt", "settingsDirRemove": "Entfernen", "settingsDirRescan": "Erneut scannen", "settingsDisplayVolumeSlider": "Lautstärkeregler im Player anzeigen", "settingsGrantDirectoryAccess": "Bitte erlauben Sie der App den Ordnerzugriff über die Browser-Berechtigungen, damit die Inhalte gescannt werden können", "settingsImportTracks": "Titel importieren", "settingsInstallAppDesktop": "Desktop", "settingsInstallAppExplanation": "Fügen Sie den Snae Player zu Ihrem {device} hinzu, um ein noch intensiveres Erlebnis zu erhalten", "settingsInstallAppHomeAction": "Installieren", "settingsInstallAppHomeScreen": "Startbildschirm", "settingsLanguage": "Sprachen", "settingsMissingFs1": "Ihr Browser unterstützt nicht die erforderlichen ", "settingsMissingFs2": "Dateisystemfunktionen,", "settingsMissingFs3": " für den vollständigen Ordnerzugriff, damit diese App funktioniert,", "settingsMissingFs4": "jede Musikdatei muss kopiert und im App-Speicher gespeichert werden,", "settingsMissingFs5": " dies könnte viel Speicherplatz auf Ihrem Gerät beanspruchen.", "settingsMotion": "Animation", "settingsMotionAuto": "Auto", "settingsMotionNormal": "Normal", "settingsMotionReduced": "Reduziert", "settingsPlaybackSpeed": "Wiedergabegeschwindigkeit", "settingsPlaybackSpeedReset": "Geschwindigkeit zurücksetzen", "settingsPreparingForScan": "Scan wird vorbereitet", "settingsPreservePitch": "Tonhöhe beibehalten", "settingsPreservePitchInfo": "Hält Stimmen und Instrumente in ihrer ursprünglichen Tonlage, während sich die Wiedergabegeschwindigkeit ändert.", "settingsPrimaryColor": "Primärfarbe der App", "settingsScanInProgress": "Titel werden gescannt. {current} von {total}", "settingsScanNewOrUpdatedTracks": "{newTracks} neue oder aktualisierte Titel gefunden", "settingsScanNoNewTracks": "Keine neuen Titel gefunden", "settingsThemeAuto": "Auto", "settingsThemeDark": "Dunkel", "settingsThemeLight": "Hell", "settingsTracksInAppStorageTooltip": "Dies enthält im App-Speicher gespeicherte Titel und/oder Daten, die Sie aus Snae Player V1 migriert haben", "settingsTracksInsideAppMemory": "Titel im App-Speicher", "shuffle": "Zufallswiedergabe", "successfullyRemovedTracks": "{count} Titel erfolgreich entfernt", "track": "Titel", "trackAddToFavorites": "Zu Favoriten hinzufügen", "trackPlay": "{name} abspielen", "trackRemoveFromFavorites": "Aus den Favoriten entfernen", "tracks": "Titel", "trackViewAlbum": "Album anzeigen", "trackViewArtist": "Künstler anzeigen", "understood": "Verstanden", "unknown": "Unbekannt", "validationMaxLength": "Es sind maximal {max} Zeichen erlaubt", "validationMinLength": "Es sind mindestens {min} Zeichen erforderlich", "validationRequired": "Eingabe erforderlich", "year": "Jahr" } ================================================ FILE: messages/en.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "About", "aboutHomepage": "Homepage", "aboutJoinDiscord": "Join our Discord", "aboutPrivacy": "Privacy", "aboutSourceCode": "Source code", "album": "Album", "albums": "Albums", "appName": "Snae Player", "appNameShort": "Snae", "appUpdateAvailable": "App update is available", "artist": "Artist", "artists": "Artists", "cancel": "Cancel", "created": "Created", "description": "Description", "directoryIsIncludedInParent": "\"{newDir}\" is subdirectory of \"{existingDir}\" which is already in your Library. You do not need to add it again.", "dismiss": "Dismiss", "duration": "Duration", "equalizerClose": "Close", "equalizerOpenEqualizer": "Open equalizer", "equalizerPresetAcoustic": "Acoustic", "equalizerPresetBassBoost": "Bass Boost", "equalizerPresetClassical": "Classical", "equalizerPresetElectronic": "Electronic", "equalizerPresetFlat": "Flat", "equalizerPresetJazz": "Jazz", "equalizerPresetPop": "Pop", "equalizerPresetRock": "Rock", "equalizerPresetTrebleBoost": "Treble Boost", "equalizerReset": "Reset", "equalizerStatusEnabled": "Enabled", "equalizerTitle": "Equalizer", "errorPageDoesNotExist": "Looks like this page doesn't exist.", "errorUnexpected": "An unexpected error occurred.", "favorites": "Favorites", "foundAnIssue": "Found an issue?", "goBack": "Go back", "goHome": "Go home", "library": "Library", "libraryAddToPlaylist": "Add to playlist", "libraryApplicationMenu": "Application menu", "libraryCancel": "Cancel", "libraryConfirmRemoveMultipleTitle": "Are you sure you want to remove these {count} items?", "libraryConfirmRemoveTitle": "Are you sure you want to remove \"{name}\"?", "libraryCreate": "Create", "libraryCreateNewPlaylist": "Create new playlist", "libraryDirPromptBrowserPermission": "Browser permission required", "libraryDirPromptExplanation": "To play music, the app needs permission to access these directories:", "libraryDirPromptGrant": "Grant", "libraryEditPlaylist": "Edit playlist", "libraryEditPlaylistName": "Edit playlist name", "libraryEmpty": "Your library is empty", "libraryImportTracks": "Import tracks", "libraryItemRemovedFromLibrary": "Item removed from library", "libraryItemsRemovedFromLibrary": "Items removed from library", "libraryNewPlaylist": "New playlist", "libraryNoResults": "No results found", "libraryNoResultsExplanation": "Try searching for something else", "libraryOpenApplicationMenu": "Open application menu", "libraryOpenSortMenu": "Open sort menu", "libraryPlaylistCreated": "Playlist \"{playlistName}\" created", "libraryPlaylistFieldName": "playlist name", "libraryPlaylistName": "Playlist name", "libraryPlaylistRemoved": "Playlist removed", "libraryPlaylistsUpdated": "Playlists updated", "libraryPlaylistUpdated": "Playlist updated", "libraryRemove": "Remove", "libraryRemoveFromLibrary": "Remove from library", "librarySave": "Save", "librarySearch": "Search", "librarySelectSomethingToBeShown": "Select something from the list to be shown here", "librarySplitViewDisable": "Disable split view layout", "librarySplitViewEnable": "Enable split view layout", "libraryStartByAdding": "Start by adding some music", "libraryToggleSortOrder": "Toggle sort order", "libraryTrackRemovedFromPlaylist": "Track removed from playlist", "libraryTrackRemoveFromPlaylist": "Remove from playlist", "libraryTracksCount": "{count} tracks", "libraryViewDetails": "View details", "more": "More", "moreOptions": "More options", "name": "Name", "noItemsToDisplay": "No items to display", "pause": "Pause", "play": "Play", "player": "Player", "playerAddToQueue": "Add to queue", "playerAudioErrorLoadError": "Failed to load audio for \"{name}\"", "playerAudioErrorNotFound": "Audio file not found for \"{name}\". It might have been moved or deleted.", "playerAudioErrorPermissionDenied": "Permission denied to load audio for \"{name}\". Please grant browser permission and try again.", "playerClearHistory": "Clear history", "playerClearQueue": "Clear queue", "playerDecreaseVolume": "Decrease volume", "playerDisableRepeat": "Disable repeat", "playerDisableShuffle": "Disable shuffle", "playerEnableRepeat": "Enable repeat", "playerEnableRepeatOne": "Enable repeat one", "playerEnableShuffle": "Enable shuffle", "playerHistory": "History", "playerHistoryEmpty": "Your play history is empty", "playerIncreaseVolume": "Increase volume", "playerOpenFullPlayer": "Open full player", "playerOpenHistory": "Open history", "playerOpenQueue": "Open queue", "playerPause": "Pause", "playerPlay": "Play", "playerPlayNextTrack": "Play next track", "playerPlayPreviousTrack": "Play previous track", "playerQueueEmpty": "Your queue is empty", "playerQueuePlaySomething": "Play something here", "playerRemoveFromHistory": "Remove from history", "playerRemoveFromQueue": "Remove from queue", "playlist": "Playlist", "playlists": "Playlists", "queue": "Queue", "reload": "Reload", "replace": "Replace", "replaceDirectoryExplanation": "The directory {newDir} you're adding is a parent of {existingDirs} which already exists in your Library.\n No changes will occur to existing tracks in your library.", "replaceDirectoryQ": "Replace directory?", "selectAll": "Select all", "selectedCount": "Selected {count}", "settingPickColorFromArtwork": "Automatically pick color from currently playing song artwork", "settings": "Settings", "settingsAbout": "About", "settingsAddDirectory": "Add directory", "settingsAllDataLocal": "All data is kept on your device", "settingsAppearance": "Appearance", "settingsApplicationTheme": "Application theme", "settingsColorPick": "Pick color", "settingsColorReset": "Reset", "settingsDbOperationInProgress": "Database operation in progress...", "settingsDirectories": "Directories", "settingsDirectoriesTracksCount": "{count} tracks", "settingsDirectoryRemoved": "Directory removed", "settingsDirRemove": "Remove", "settingsDirRescan": "Rescan", "settingsDisplayVolumeSlider": "Display volume slider inside player", "settingsGrantDirectoryAccess": "You need to allow the app access to the directory, via browser permission so it can scan its contents", "settingsImportTracks": "Import tracks", "settingsInstallAppDesktop": "desktop", "settingsInstallAppExplanation": "Add Snae Player to your {device} for more immersive experience", "settingsInstallAppHomeAction": "Install", "settingsInstallAppHomeScreen": "home screen", "settingsLanguage": "Language", "settingsMissingFs1": "Your browser does not support required ", "settingsMissingFs2": "File System features,", "settingsMissingFs3": " for full directory access so in order for this app to work,", "settingsMissingFs4": "each music file must be copied and saved inside app storage,", "settingsMissingFs5": " that might take up a lot of your device's disk space.", "settingsMotion": "Motion", "settingsMotionAuto": "Auto", "settingsMotionNormal": "Normal", "settingsMotionReduced": "Reduced", "settingsPlaybackSpeed": "Playback speed", "settingsPlaybackSpeedReset": "Reset speed", "settingsPreparingForScan": "Preparing for the scan", "settingsPreservePitch": "Preserve pitch", "settingsPreservePitchInfo": "Keeps voices and instruments at their original tone while changing playback speed.", "settingsPrimaryColor": "Application primary color", "settingsScanInProgress": "Scanning tracks. {current} of {total}", "settingsScanNewOrUpdatedTracks": "Found {newTracks} new or updated tracks", "settingsScanNoNewTracks": "No new tracks were found", "settingsThemeAuto": "Auto", "settingsThemeDark": "Dark", "settingsThemeLight": "Light", "settingsTracksInAppStorageTooltip": "This contains tracks stored in app storage and/or data you migrated from Snae Player v1", "settingsTracksInsideAppMemory": "Tracks inside app storage", "shuffle": "Shuffle", "successfullyRemovedTracks": "Successfully removed {count} tracks", "track": "Track", "trackAddToFavorites": "Add to favorites", "trackPlay": "Play {name}", "trackRemoveFromFavorites": "Remove from favorites", "tracks": "Tracks", "trackViewAlbum": "View album", "trackViewArtist": "View artist", "understood": "Understood", "unknown": "Unknown", "validationMaxLength": "At most {max} characters are allowed", "validationMinLength": "At least {min} characters are required", "validationRequired": "Field is required", "year": "Year" } ================================================ FILE: messages/fr.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "À propos", "aboutHomepage": "Page d’accueil", "aboutJoinDiscord": "Rejoindre notre Discord", "aboutPrivacy": "Confidentialité", "aboutSourceCode": "Code source", "album": "Album", "albums": "Albums", "appName": "Snae Player", "appNameShort": "Snae", "appUpdateAvailable": "Une mise à jour est disponible", "artist": "Artiste", "artists": "Artistes", "cancel": "Annuler", "created": "Créé", "description": "Description", "directoryIsIncludedInParent": "« {newDir} » est un sous-dossier de « {existingDir} » qui est déjà dans votre bibliothèque. Vous n’avez pas besoin de l’ajouter à nouveau.", "dismiss": "Fermer", "duration": "Durée", "equalizerClose": "Fermer", "equalizerOpenEqualizer": "Ouvrir l'égaliseur", "equalizerPresetAcoustic": "Acoustique", "equalizerPresetBassBoost": "Renforcement des basses", "equalizerPresetClassical": "Classique", "equalizerPresetElectronic": "Électronique", "equalizerPresetFlat": "Plat", "equalizerPresetJazz": "Jazz", "equalizerPresetPop": "Pop", "equalizerPresetRock": "Rock", "equalizerPresetTrebleBoost": "Renforcement des aigus", "equalizerReset": "Réinitialiser", "equalizerStatusEnabled": "Activé", "equalizerTitle": "Égaliseur", "errorPageDoesNotExist": "Il semble que cette page n’existe pas.", "errorUnexpected": "Une erreur inattendue s’est produite.", "favorites": "Favoris", "foundAnIssue": "Vous avez trouvé un problème ?", "goBack": "Retour", "goHome": "Aller à l'accueil", "library": "Bibliothèque", "libraryAddToPlaylist": "Ajouter à la liste de lecture", "libraryApplicationMenu": "Menu de l’application", "libraryCancel": "Annuler", "libraryConfirmRemoveMultipleTitle": "Êtes-vous sûr de vouloir supprimer ces {count} éléments ?", "libraryConfirmRemoveTitle": "Êtes-vous sûr de vouloir supprimer « {name} » ?", "libraryCreate": "Créer", "libraryCreateNewPlaylist": "Créer une nouvelle liste de lecture", "libraryDirPromptBrowserPermission": "Autorisation du navigateur requise", "libraryDirPromptExplanation": "Pour lire la musique, l’application a besoin d’accéder à ces dossiers :", "libraryDirPromptGrant": "Autoriser", "libraryEditPlaylist": "Modifier la liste de lecture", "libraryEditPlaylistName": "Modifier le nom de la liste de lecture", "libraryEmpty": "Votre bibliothèque est vide", "libraryImportTracks": "Importer des pistes", "libraryItemRemovedFromLibrary": "Élément supprimé de la bibliothèque", "libraryItemsRemovedFromLibrary": "Éléments supprimés de la bibliothèque", "libraryNewPlaylist": "Nouvelle liste de lecture", "libraryNoResults": "Aucun résultat trouvé", "libraryNoResultsExplanation": "Essayez de rechercher autre chose", "libraryOpenApplicationMenu": "Ouvrir le menu de l’application", "libraryOpenSortMenu": "Ouvrir le menu de tri", "libraryPlaylistCreated": "Liste de lecture « {playlistName} » créée", "libraryPlaylistFieldName": "nom de la liste de lecture", "libraryPlaylistName": "Nom de la liste de lecture", "libraryPlaylistRemoved": "Liste de lecture supprimée", "libraryPlaylistsUpdated": "Listes de lecture mises à jour", "libraryPlaylistUpdated": "Liste de lecture mise à jour", "libraryRemove": "Supprimer", "libraryRemoveFromLibrary": "Supprimer de la bibliothèque", "librarySave": "Enregistrer", "librarySearch": "Rechercher", "librarySelectSomethingToBeShown": "Sélectionnez un élément de la liste pour l’afficher ici", "librarySplitViewDisable": "Désactiver la vue partagée", "librarySplitViewEnable": "Activer la vue partagée", "libraryStartByAdding": "Commencez par ajouter de la musique", "libraryToggleSortOrder": "Changer l’ordre de tri", "libraryTrackRemovedFromPlaylist": "Piste retirée de la liste de lecture", "libraryTrackRemoveFromPlaylist": "Retirer de la liste de lecture", "libraryTracksCount": "{count} pistes", "libraryViewDetails": "Voir les détails", "more": "Plus", "moreOptions": "Plus d’options", "name": "Nom", "noItemsToDisplay": "Aucun élément à afficher", "pause": "Pause", "play": "Lecture", "player": "Lecteur", "playerAddToQueue": "Ajouter à la file d’attente", "playerAudioErrorLoadError": "Impossible de charger l’audio pour \"{name}\"", "playerAudioErrorNotFound": "Fichier audio introuvable pour \"{name}\". Il a peut-être été déplacé ou supprimé.", "playerAudioErrorPermissionDenied": "Autorisation refusée pour charger l’audio de \"{name}\". Veuillez accorder la permission du navigateur et réessayer.", "playerClearHistory": "Effacer l'historique", "playerClearQueue": "Vider la file d’attente", "playerDecreaseVolume": "Diminuer le volume", "playerDisableRepeat": "Désactiver la répétition", "playerDisableShuffle": "Désactiver la lecture aléatoire", "playerEnableRepeat": "Activer la répétition", "playerEnableRepeatOne": "Répéter une seule piste", "playerEnableShuffle": "Activer la lecture aléatoire", "playerHistory": "Historique", "playerHistoryEmpty": "Votre historique d'écoute est vide", "playerIncreaseVolume": "Augmenter le volume", "playerOpenFullPlayer": "Ouvrir le lecteur en plein écran", "playerOpenHistory": "Ouvrir l'historique", "playerOpenQueue": "Ouvrir la file d’attente", "playerPause": "Pause", "playerPlay": "Lecture", "playerPlayNextTrack": "Piste suivante", "playerPlayPreviousTrack": "Piste précédente", "playerQueueEmpty": "Votre file d’attente est vide", "playerQueuePlaySomething": "Lancez une lecture ici", "playerRemoveFromHistory": "Retirer de l'historique", "playerRemoveFromQueue": "Retirer de la file d’attente", "playlist": "Liste de lecture", "playlists": "Listes de lecture", "queue": "File d’attente", "reload": "Recharger", "replace": "Remplacer", "replaceDirectoryExplanation": "Le dossier {newDir} que vous ajoutez contient {existingDirs} qui existe déjà dans votre bibliothèque.\n Aucune modification ne sera apportée aux pistes existantes de votre bibliothèque.", "replaceDirectoryQ": "Remplacer le dossier ?", "selectAll": "Tout sélectionner", "selectedCount": "{count} sélectionnés", "settingPickColorFromArtwork": "Choisir automatiquement la couleur à partir de la pochette de la piste en cours", "settings": "Paramètres", "settingsAbout": "À propos", "settingsAddDirectory": "Ajouter un dossier", "settingsAllDataLocal": "Toutes les données sont conservées sur votre appareil", "settingsAppearance": "Apparence", "settingsApplicationTheme": "Thème de l’application", "settingsColorPick": "Choisir la couleur", "settingsColorReset": "Réinitialiser", "settingsDbOperationInProgress": "Opération de base de données en cours…", "settingsDirectories": "Dossiers", "settingsDirectoriesTracksCount": "{count} pistes", "settingsDirectoryRemoved": "Dossier supprimé", "settingsDirRemove": "Supprimer", "settingsDirRescan": "Réanalyser", "settingsDisplayVolumeSlider": "Afficher le curseur de volume dans le lecteur", "settingsGrantDirectoryAccess": "Vous devez autoriser l’appli à accéder au dossier via une permission du navigateur afin qu’elle puisse analyser son contenu", "settingsImportTracks": "Importer des pistes", "settingsInstallAppDesktop": "bureau", "settingsInstallAppExplanation": "Ajoutez Snae Player à votre {device} pour une expérience plus immersive", "settingsInstallAppHomeAction": "Installer", "settingsInstallAppHomeScreen": "écran d’accueil", "settingsLanguage": "Langue", "settingsMissingFs1": "Votre navigateur ne prend pas en charge les ", "settingsMissingFs2": "fonctionnalités du système de fichiers,", "settingsMissingFs3": " nécessaires pour l’accès complet aux dossiers. L’application doit", "settingsMissingFs4": " donc copier chaque fichier musical dans son propre stockage, ce", "settingsMissingFs5": " qui peut occuper beaucoup d’espace sur votre appareil.", "settingsMotion": "Animation", "settingsMotionAuto": "Auto", "settingsMotionNormal": "Normal", "settingsMotionReduced": "Réduit", "settingsPlaybackSpeed": "Vitesse de lecture", "settingsPlaybackSpeedReset": "Réinitialiser la vitesse", "settingsPreparingForScan": "Préparation de l’analyse", "settingsPreservePitch": "Conserver la hauteur", "settingsPreservePitchInfo": "Conserve la tonalité d'origine des voix et des instruments lorsque la vitesse de lecture change.", "settingsPrimaryColor": "Couleur principale de l’application", "settingsScanInProgress": "Analyse des pistes. {current} sur {total}", "settingsScanNewOrUpdatedTracks": "{newTracks} nouvelles pistes ou pistes mises à jour trouvées", "settingsScanNoNewTracks": "Aucune nouvelle piste trouvée", "settingsThemeAuto": "Auto", "settingsThemeDark": "Sombre", "settingsThemeLight": "Clair", "settingsTracksInAppStorageTooltip": "Contient les pistes stockées dans le stockage de l'application et/ou les données migrées depuis Snae Player v1", "settingsTracksInsideAppMemory": "Pistes dans le stockage de l’application", "shuffle": "Aléatoire", "successfullyRemovedTracks": "{count} pistes supprimées avec succès", "track": "Piste", "trackAddToFavorites": "Ajouter aux favoris", "trackPlay": "Lire {name}", "trackRemoveFromFavorites": "Retirer des favoris", "tracks": "Pistes", "trackViewAlbum": "Voir l’album", "trackViewArtist": "Voir l’artiste", "understood": "Compris", "unknown": "Inconnu", "validationMaxLength": "{max} caractères maximum autorisés", "validationMinLength": "{min} caractères minimum requis", "validationRequired": "Champ obligatoire", "year": "Année" } ================================================ FILE: messages/lt.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "Apie", "aboutHomepage": "Namai", "aboutJoinDiscord": "Discord", "aboutPrivacy": "Privatumas", "aboutSourceCode": "Programos kodas", "album": "Albumas", "albums": "Albumai", "appName": "Snae grotuvas", "appNameShort": "Snae", "appUpdateAvailable": "Galimas programos atnaujinimas", "artist": "Atlikėjas", "artists": "Atlikėjai", "cancel": "Atšaukti", "created": "Sukurta", "description": "Aprašymas", "directoryIsIncludedInParent": "\"{newDir}\" yra \"{existingDir}\" poaplankis, kuris jau yra jūsų bibliotekoje. Jums nereikia jo pridėti dar kartą.", "dismiss": "Uždaryti", "duration": "Trukmė", "equalizerClose": "Uždaryti", "equalizerOpenEqualizer": "Atidaryti ekvalaizerį", "equalizerPresetAcoustic": "Akustinis", "equalizerPresetBassBoost": "Paryškinti žemus dažnius", "equalizerPresetClassical": "Klasikinis", "equalizerPresetElectronic": "Elektroninis", "equalizerPresetFlat": "Neutralus", "equalizerPresetJazz": "Džiazas", "equalizerPresetPop": "Pop", "equalizerPresetRock": "Rokas", "equalizerPresetTrebleBoost": "Paryškinti aukštus dažnius", "equalizerReset": "Atstatyti", "equalizerStatusEnabled": "Įjungtas", "equalizerTitle": "Ekvalaizeris", "errorPageDoesNotExist": "Panašu, kad šio puslapio nėra.", "errorUnexpected": "Įvyko netikėta klaida.", "favorites": "Mėgstamiausi", "foundAnIssue": "Radote klaidą?", "goBack": "Grįžti", "goHome": "Grįžti į pradžią", "library": "Biblioteka", "libraryAddToPlaylist": "Pridėti į grojaraštį", "libraryApplicationMenu": "Programos meniu", "libraryCancel": "Atšaukti", "libraryConfirmRemoveMultipleTitle": "Ar tikrai norite pašalinti šiuos {count} elementus?", "libraryConfirmRemoveTitle": "Ar tikrai norite pašalinti „{name}“?", "libraryCreate": "Sukurti", "libraryCreateNewPlaylist": "Sukurti naują grojaraštį", "libraryDirPromptBrowserPermission": "Reikalingas naršyklės leidimas", "libraryDirPromptExplanation": "Kad galėtumėte klausytis muzikos, programai reikia leidimo pasiekti šiuos katalogus:", "libraryDirPromptGrant": "Suteikti", "libraryEditPlaylist": "Redaguoti grojaraštį", "libraryEditPlaylistName": "Redaguoti grojaraščio pavadinimą", "libraryEmpty": "Jūsų biblioteka tuščia", "libraryImportTracks": "Importuoti kūrinius", "libraryItemRemovedFromLibrary": "Elementas pašalintas iš bibliotekos", "libraryItemsRemovedFromLibrary": "Elementai pašalinti iš bibliotekos", "libraryNewPlaylist": "Naujas grojaraštis", "libraryNoResults": "Rezultatų nerasta", "libraryNoResultsExplanation": "Pabandykite ieškoti kitaip", "libraryOpenApplicationMenu": "Atidaryti programos meniu", "libraryOpenSortMenu": "Atidaryti rūšiavimo meniu", "libraryPlaylistCreated": "Grojaraštis „{playlistName}“ sukurtas", "libraryPlaylistFieldName": "grojaraščio pavadinimas", "libraryPlaylistName": "Grojaraščio pavadinimas", "libraryPlaylistRemoved": "Grojaraštis pašalintas", "libraryPlaylistsUpdated": "Grojaraščiai atnaujinti", "libraryPlaylistUpdated": "Grojaraštis atnaujintas", "libraryRemove": "Pašalinti", "libraryRemoveFromLibrary": "Pašalinti iš bibliotekos", "librarySave": "Išsaugoti", "librarySearch": "Paieška", "librarySelectSomethingToBeShown": "Pasirinkite ką nors iš sąrašo, kad būtų rodoma čia", "librarySplitViewDisable": "Išjungti padalintą išdėstymą", "librarySplitViewEnable": "Įjungti padalintą išdėstymą", "libraryStartByAdding": "Pradėkite pridėdami muzikos", "libraryToggleSortOrder": "Perjungti rūšiavimo tvarką", "libraryTrackRemovedFromPlaylist": "Kūrinys pašalintas iš grojaraščio", "libraryTrackRemoveFromPlaylist": "Pašalinti iš grojaraščio", "libraryTracksCount": "{count} kūriniai", "libraryViewDetails": "Peržiūrėti detales", "more": "Daugiau", "moreOptions": "Daugiau parinkčių", "name": "Pavadinimas", "noItemsToDisplay": "Nėra elementų rodymui", "pause": "Pristabdyti", "play": "Groti", "player": "Grotuvas", "playerAddToQueue": "Pridėti į eilę", "playerAudioErrorLoadError": "Nepavyko įkelti garso įrašo \"{name}\"", "playerAudioErrorNotFound": "Garso failas \"{name}\" nerastas. Gali būti perkeltas arba ištrintas.", "playerAudioErrorPermissionDenied": "Trūksta leidimo įkelti garso įrašą \"{name}\". Suteikite naršyklės leidimą ir bandykite dar kartą.", "playerClearHistory": "Išvalyti istoriją", "playerClearQueue": "Išvalyti eilę", "playerDecreaseVolume": "Sumažinti garsumą", "playerDisableRepeat": "Išjungti kartojimą", "playerDisableShuffle": "Išjungti maišymą", "playerEnableRepeat": "Įjungti kartojimą", "playerEnableRepeatOne": "Įjungti vieno kūrinio kartojimą", "playerEnableShuffle": "Įjungti maišymą", "playerHistory": "Istorija", "playerHistoryEmpty": "Jūsų klausymo istorija tuščia", "playerIncreaseVolume": "Padidinti garsumą", "playerOpenFullPlayer": "Atidaryti pilną grotuvą", "playerOpenHistory": "Atidaryti istoriją", "playerOpenQueue": "Atidaryti eilę", "playerPause": "Pristabdyti", "playerPlay": "Groti", "playerPlayNextTrack": "Groti kitą kūrinį", "playerPlayPreviousTrack": "Groti ankstesnį kūrinį", "playerQueueEmpty": "Jūsų eilė tuščia", "playerQueuePlaySomething": "Grokite ką nors čia", "playerRemoveFromHistory": "Pašalinti iš istorijos", "playerRemoveFromQueue": "Pašalinti iš eilės", "playlist": "Grojaraštis", "playlists": "Grojaraščiai", "queue": "Eilė", "reload": "Įkelti iš naujo", "replace": "Pakeisti", "replaceDirectoryExplanation": "Katalogas {newDir}, kurį pridedate, yra {existingDirs} pirminis katalogas, kuris jau egzistuoja jūsų bibliotekoje.\n Esamiems kūriniams bibliotekoje niekas nepasikeis.", "replaceDirectoryQ": "Pakeisti katalogą?", "selectAll": "Pasirinkti viską", "selectedCount": "Pasirinkta: {count}", "settingPickColorFromArtwork": "Automatiškai parinkti spalvą pagal šiuo metu grojančio kūrinio viršelį", "settings": "Nustatymai", "settingsAbout": "Apie", "settingsAddDirectory": "Pridėti katalogą", "settingsAllDataLocal": "Visi duomenys išsaugomi jūsų įrenginyje", "settingsAppearance": "Išvaizda", "settingsApplicationTheme": "Programos tema", "settingsColorPick": "Pasirinkti spalvą", "settingsColorReset": "Atstatyti", "settingsDbOperationInProgress": "Vykdoma duomenų bazės operacija...", "settingsDirectories": "Katalogai", "settingsDirectoriesTracksCount": "{count} kūriniai", "settingsDirectoryRemoved": "Katalogas pašalintas", "settingsDirRemove": "Pašalinti", "settingsDirRescan": "Skenuoti iš naujo", "settingsDisplayVolumeSlider": "Rodyti garso slankiklį grotuve", "settingsGrantDirectoryAccess": "Reikia suteikti programai prieigą prie katalogo per naršyklės leidimą, kad ji galėtų nuskaityti jo turinį", "settingsImportTracks": "Importuoti kūrinius", "settingsInstallAppDesktop": "kompiuterį", "settingsInstallAppExplanation": "Pridėkite Snae grotuvą prie savo {device} patogesnei patirčiai", "settingsInstallAppHomeAction": "Įdiegti", "settingsInstallAppHomeScreen": "pagrindinis ekranas", "settingsLanguage": "Kalba", "settingsMissingFs1": "Jūsų naršyklė nepalaiko reikalingų ", "settingsMissingFs2": "Failų sistemos funkcijų,", "settingsMissingFs3": " kad būtų galima visiškai pasiekti katalogus, todėl šiai programai veikti,", "settingsMissingFs4": "kiekvieną muzikos failą reikia nukopijuoti ir išsaugoti programos saugykloje,", "settingsMissingFs5": " tai gali užimti daug vietos jūsų įrenginyje.", "settingsMotion": "Animacijos", "settingsMotionAuto": "Automatinės", "settingsMotionNormal": "Normalios", "settingsMotionReduced": "Sumažintos", "settingsPlaybackSpeed": "Atkūrimo greitis", "settingsPlaybackSpeedReset": "Atstatyti greitį", "settingsPreparingForScan": "Ruošiamasi skenavimui", "settingsPreservePitch": "Išlaikyti toną", "settingsPreservePitchInfo": "Keičiant atkūrimo greitį, balsų ir instrumentų tonas išlieka originalus.", "settingsPrimaryColor": "Pagrindinė programos spalva", "settingsScanInProgress": "Skenuojami kūriniai. {current} iš {total}", "settingsScanNewOrUpdatedTracks": "Rasta {newTracks} naujų arba atnaujintų kūrinių", "settingsScanNoNewTracks": "Naujų kūrinių nerasta", "settingsThemeAuto": "Automatinė", "settingsThemeDark": "Tamsi", "settingsThemeLight": "Šviesi", "settingsTracksInAppStorageTooltip": "Tai apima kūrinius, saugomus programos saugykloje ir/arba duomenis, perkeltus iš Snae Player v1", "settingsTracksInsideAppMemory": "Kūriniai programos saugykloje", "shuffle": "Maišyti", "successfullyRemovedTracks": "Sėkmingai pašalinta {count} kūrinių", "track": "Kūrinys", "trackAddToFavorites": "Pridėti prie mėgstamiausių", "trackPlay": "Groti {name}", "trackRemoveFromFavorites": "Pašalinti iš mėgstamiausių", "tracks": "Kūriniai", "trackViewAlbum": "Peržiūrėti albumą", "trackViewArtist": "Peržiūrėti atlikėją", "understood": "Supratau", "unknown": "Nežinoma", "validationMaxLength": "Leidžiama daugiausiai {max} simbolių", "validationMinLength": "Reikalinga bent {min} simbolių", "validationRequired": "Laukas yra privalomas", "year": "Metai" } ================================================ FILE: messages/zh-CN.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "关于", "aboutHomepage": "主页", "aboutJoinDiscord": "加入我们的 Discord", "aboutPrivacy": "隐私", "aboutSourceCode": "源代码", "album": "专辑", "albums": "专辑", "appName": "Snae 播放器", "appNameShort": "Snae", "appUpdateAvailable": "应用有可用更新", "artist": "艺术家", "artists": "艺术家", "cancel": "取消", "created": "创建时间", "description": "描述", "directoryIsIncludedInParent": "\"{newDir}\" 是 \"{existingDir}\" 的子目录,而 \"{existingDir}\" 已经在您的媒体库中。您无需再次添加。", "dismiss": "忽略", "duration": "时长", "equalizerClose": "关闭", "equalizerOpenEqualizer": "打开均衡器", "equalizerPresetAcoustic": "原声", "equalizerPresetBassBoost": "低音增强", "equalizerPresetClassical": "古典", "equalizerPresetElectronic": "电子", "equalizerPresetFlat": "平直", "equalizerPresetJazz": "爵士", "equalizerPresetPop": "流行", "equalizerPresetRock": "摇滚", "equalizerPresetTrebleBoost": "高音增强", "equalizerReset": "重置", "equalizerStatusEnabled": "已启用", "equalizerTitle": "均衡器", "errorPageDoesNotExist": "看起来此页面不存在。", "errorUnexpected": "发生了一个意外错误。", "favorites": "收藏夹", "foundAnIssue": "发现问题?", "goBack": "返回", "goHome": "回到首页", "library": "媒体库", "libraryAddToPlaylist": "添加到播放列表", "libraryApplicationMenu": "应用菜单", "libraryCancel": "取消", "libraryConfirmRemoveMultipleTitle": "您确定要移除这 {count} 项吗?", "libraryConfirmRemoveTitle": "您确定要移除 \"{name}\" 吗?", "libraryCreate": "创建", "libraryCreateNewPlaylist": "创建新播放列表", "libraryDirPromptBrowserPermission": "需要浏览器权限", "libraryDirPromptExplanation": "要播放音乐,应用需要访问这些目录的权限:", "libraryDirPromptGrant": "授权", "libraryEditPlaylist": "编辑播放列表", "libraryEditPlaylistName": "编辑播放列表名称", "libraryEmpty": "您的媒体库是空的", "libraryImportTracks": "导入曲目", "libraryItemRemovedFromLibrary": "项目已从媒体库中移除", "libraryItemsRemovedFromLibrary": "项目已从媒体库中移除", "libraryNewPlaylist": "新播放列表", "libraryNoResults": "未找到结果", "libraryNoResultsExplanation": "尝试搜索其他内容", "libraryOpenApplicationMenu": "打开应用菜单", "libraryOpenSortMenu": "打开排序菜单", "libraryPlaylistCreated": "已创建播放列表 \"{playlistName}\"", "libraryPlaylistFieldName": "播放列表名称", "libraryPlaylistName": "播放列表名称", "libraryPlaylistRemoved": "播放列表已移除", "libraryPlaylistsUpdated": "播放列表已更新", "libraryPlaylistUpdated": "播放列表已更新", "libraryRemove": "移除", "libraryRemoveFromLibrary": "从媒体库中移除", "librarySave": "保存", "librarySearch": "搜索", "librarySelectSomethingToBeShown": "从列表中选择要在此处显示的内容", "librarySplitViewDisable": "禁用分屏视图布局", "librarySplitViewEnable": "启用分屏视图布局", "libraryStartByAdding": "首先添加一些音乐", "libraryToggleSortOrder": "切换排序方式", "libraryTrackRemovedFromPlaylist": "曲目已从播放列表中移除", "libraryTrackRemoveFromPlaylist": "从播放列表中移除", "libraryTracksCount": "{count} 首曲目", "libraryViewDetails": "查看详情", "more": "更多", "moreOptions": "更多选项", "name": "名称", "noItemsToDisplay": "没有要显示的项目", "pause": "暂停", "play": "播放", "player": "播放器", "playerAddToQueue": "添加到队列", "playerAudioErrorLoadError": "无法加载\"{name}\"的音频", "playerAudioErrorNotFound": "未找到\"{name}\"的音频文件。该文件可能已被移动或删除。", "playerAudioErrorPermissionDenied": "没有权限加载\"{name}\"的音频。请授予浏览器权限后重试。", "playerClearHistory": "清除历史记录", "playerClearQueue": "清空队列", "playerDecreaseVolume": "降低音量", "playerDisableRepeat": "禁用重复播放", "playerDisableShuffle": "禁用随机播放", "playerEnableRepeat": "启用重复播放", "playerEnableRepeatOne": "启用单曲循环", "playerEnableShuffle": "启用随机播放", "playerHistory": "历史记录", "playerHistoryEmpty": "您还没有播放任何内容", "playerIncreaseVolume": "增加音量", "playerOpenFullPlayer": "打开完整播放器", "playerOpenHistory": "打开历史记录", "playerOpenQueue": "打开队列", "playerPause": "暂停", "playerPlay": "播放", "playerPlayNextTrack": "播放下一首曲目", "playerPlayPreviousTrack": "播放上一首曲目", "playerQueueEmpty": "您的队列是空的", "playerQueuePlaySomething": "在此播放一些内容", "playerRemoveFromHistory": "从历史记录中移除", "playerRemoveFromQueue": "从队列中移除", "playlist": "播放列表", "playlists": "播放列表", "queue": "队列", "reload": "重新加载", "replace": "替换", "replaceDirectoryExplanation": "您要添加的目录 {newDir} 是 {existingDirs} 的父目录,而 {existingDirs} 已经存在于您的媒体库中。\n 对您媒体库中现有的曲目不会进行任何更改。", "replaceDirectoryQ": "替换目录?", "selectAll": "全选", "selectedCount": "已选择 {count}", "settingPickColorFromArtwork": "自动从当前播放歌曲的专辑封面中选取颜色", "settings": "设置", "settingsAbout": "关于", "settingsAddDirectory": "添加目录", "settingsAllDataLocal": "所有数据都保存在您的设备上", "settingsAppearance": "外观", "settingsApplicationTheme": "应用主题", "settingsColorPick": "选取颜色", "settingsColorReset": "重置", "settingsDbOperationInProgress": "数据库操作正在进行中...", "settingsDirectories": "目录", "settingsDirectoriesTracksCount": "{count} 首曲目", "settingsDirectoryRemoved": "目录已移除", "settingsDirRemove": "移除", "settingsDirRescan": "重新扫描", "settingsDisplayVolumeSlider": "在播放器内显示音量滑块", "settingsGrantDirectoryAccess": "您需要允许应用访问该目录,通过浏览器权限,以便它可以扫描其内容", "settingsImportTracks": "导入曲目", "settingsInstallAppDesktop": "桌面版", "settingsInstallAppExplanation": "将 Snae 播放器添加到您的 {device} 以获得更沉浸式的体验", "settingsInstallAppHomeAction": "安装", "settingsInstallAppHomeScreen": "主屏幕", "settingsLanguage": "语言", "settingsMissingFs1": "您的浏览器不支持所需的 ", "settingsMissingFs2": "文件系统功能,", "settingsMissingFs3": " 为了使此应用正常工作,", "settingsMissingFs4": "每个音乐文件都必须复制并保存在应用存储中,", "settingsMissingFs5": " 这可能会占用您设备的大量磁盘空间。", "settingsMotion": "动画", "settingsMotionAuto": "自动", "settingsMotionNormal": "正常", "settingsMotionReduced": "减少", "settingsPlaybackSpeed": "播放速度", "settingsPlaybackSpeedReset": "重置速度", "settingsPreparingForScan": "准备扫描", "settingsPreservePitch": "保持音高", "settingsPreservePitchInfo": "在更改播放速度时,保持人声和乐器的原始音高不变。", "settingsPrimaryColor": "应用主色", "settingsScanInProgress": "正在扫描曲目。{current}/{total}", "settingsScanNewOrUpdatedTracks": "找到 {newTracks} 首新的或更新的曲目", "settingsScanNoNewTracks": "未找到新的曲目", "settingsThemeAuto": "自动", "settingsThemeDark": "深色", "settingsThemeLight": "浅色", "settingsTracksInAppStorageTooltip": "这包含存储在应用存储中的曲目和/或您从 Snae Player v1 迁移的数据", "settingsTracksInsideAppMemory": "应用存储中的曲目", "shuffle": "随机播放", "successfullyRemovedTracks": "成功移除 {count} 首曲目", "track": "曲目", "trackAddToFavorites": "添加到收藏夹", "trackPlay": "播放 {name}", "trackRemoveFromFavorites": "从收藏夹中移除", "tracks": "曲目", "trackViewAlbum": "查看专辑", "trackViewArtist": "查看艺术家", "understood": "明白", "unknown": "未知", "validationMaxLength": "最多允许 {max} 个字符", "validationMinLength": "至少需要 {min} 个字符", "validationRequired": "必填字段", "year": "年份" } ================================================ FILE: messages/zh-TW.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "about": "關於", "aboutHomepage": "首頁", "aboutJoinDiscord": "加入我們的 Discord", "aboutPrivacy": "隱私", "aboutSourceCode": "原始碼", "album": "專輯", "albums": "專輯", "appName": "Snae 播放器", "appNameShort": "Snae", "appUpdateAvailable": "應用程式有可用更新", "artist": "藝術家", "artists": "藝術家", "cancel": "取消", "created": "建立時間", "description": "描述", "directoryIsIncludedInParent": "「{newDir}」是「{existingDir}」的子目錄,而「{existingDir}」已經在您的媒體庫中。您無需再次新增。", "dismiss": "忽略", "duration": "時長", "equalizerClose": "關閉", "equalizerOpenEqualizer": "開啟等化器", "equalizerPresetAcoustic": "原聲", "equalizerPresetBassBoost": "低音增強", "equalizerPresetClassical": "古典", "equalizerPresetElectronic": "電子", "equalizerPresetFlat": "平直", "equalizerPresetJazz": "爵士", "equalizerPresetPop": "流行", "equalizerPresetRock": "搖滾", "equalizerPresetTrebleBoost": "高音增強", "equalizerReset": "重設", "equalizerStatusEnabled": "已啟用", "equalizerTitle": "等化器", "errorPageDoesNotExist": "看起來此頁面不存在。", "errorUnexpected": "發生了一個意外錯誤。", "favorites": "收藏夾", "foundAnIssue": "發現問題?", "goBack": "返回", "goHome": "回到首頁", "library": "媒體庫", "libraryAddToPlaylist": "新增到播放列表", "libraryApplicationMenu": "應用程式選單", "libraryCancel": "取消", "libraryConfirmRemoveMultipleTitle": "您確定要移除這 {count} 項嗎?", "libraryConfirmRemoveTitle": "您確定要移除「{name}」嗎?", "libraryCreate": "建立", "libraryCreateNewPlaylist": "建立新播放列表", "libraryDirPromptBrowserPermission": "需要瀏覽器權限", "libraryDirPromptExplanation": "要播放音樂,應用程式需要存取這些目錄的權限:", "libraryDirPromptGrant": "授權", "libraryEditPlaylist": "編輯播放列表", "libraryEditPlaylistName": "編輯播放列表名稱", "libraryEmpty": "您的媒體庫是空的", "libraryImportTracks": "匯入曲目", "libraryItemRemovedFromLibrary": "項目已從媒體庫中移除", "libraryItemsRemovedFromLibrary": "項目已從媒體庫中移除", "libraryNewPlaylist": "新播放列表", "libraryNoResults": "未找到結果", "libraryNoResultsExplanation": "嘗試搜尋其他內容", "libraryOpenApplicationMenu": "開啟應用程式選單", "libraryOpenSortMenu": "開啟排序選單", "libraryPlaylistCreated": "已建立播放列表「{playlistName}」", "libraryPlaylistFieldName": "播放列表名稱", "libraryPlaylistName": "播放列表名稱", "libraryPlaylistRemoved": "播放列表已移除", "libraryPlaylistsUpdated": "播放列表已更新", "libraryPlaylistUpdated": "播放列表已更新", "libraryRemove": "移除", "libraryRemoveFromLibrary": "從媒體庫中移除", "librarySave": "儲存", "librarySearch": "搜尋", "librarySelectSomethingToBeShown": "從列表中選擇要在此處顯示的內容", "librarySplitViewDisable": "停用分屏檢視佈局", "librarySplitViewEnable": "啟用分屏檢視佈局", "libraryStartByAdding": "首先新增一些音樂", "libraryToggleSortOrder": "切換排序方式", "libraryTrackRemovedFromPlaylist": "曲目已從播放列表中移除", "libraryTrackRemoveFromPlaylist": "從播放列表中移除", "libraryTracksCount": "{count} 首曲目", "libraryViewDetails": "檢視詳細資訊", "more": "更多", "moreOptions": "更多選項", "name": "名稱", "noItemsToDisplay": "沒有要顯示的項目", "pause": "暫停", "play": "播放", "player": "播放器", "playerAddToQueue": "新增到佇列", "playerAudioErrorLoadError": "無法載入\"{name}\"的音訊", "playerAudioErrorNotFound": "找不到\"{name}\"的音訊檔案。該檔案可能已被移動或刪除。", "playerAudioErrorPermissionDenied": "沒有權限載入\"{name}\"的音訊。請授予瀏覽器權限後再試一次。", "playerClearHistory": "清除歷史記錄", "playerClearQueue": "清空佇列", "playerDecreaseVolume": "降低音量", "playerDisableRepeat": "停用重複播放", "playerDisableShuffle": "停用隨機播放", "playerEnableRepeat": "啟用重複播放", "playerEnableRepeatOne": "啟用單曲循環", "playerEnableShuffle": "啟用隨機播放", "playerHistory": "歷史記錄", "playerHistoryEmpty": "您的播放歷史記錄為空", "playerIncreaseVolume": "增加音量", "playerOpenFullPlayer": "開啟完整播放器", "playerOpenHistory": "開啟歷史記錄", "playerOpenQueue": "開啟佇列", "playerPause": "暫停", "playerPlay": "播放", "playerPlayNextTrack": "播放下一首曲目", "playerPlayPreviousTrack": "播放上一首曲目", "playerQueueEmpty": "您的佇列是空的", "playerQueuePlaySomething": "在此播放一些內容", "playerRemoveFromHistory": "從歷史記錄中移除", "playerRemoveFromQueue": "從佇列中移除", "playlist": "播放列表", "playlists": "播放列表", "queue": "佇列", "reload": "重新載入", "replace": "取代", "replaceDirectoryExplanation": "您要新增的目錄 {newDir} 是 {existingDirs} 的父目錄,而 {existingDirs} 已經存在於您的媒體庫中。\n 對您媒體庫中現有的曲目不會進行任何變更。", "replaceDirectoryQ": "取代目錄?", "selectAll": "全選", "selectedCount": "已選取 {count}", "settingPickColorFromArtwork": "自動從目前播放歌曲的專輯封面中選取顏色", "settings": "設定", "settingsAbout": "關於", "settingsAddDirectory": "新增目錄", "settingsAllDataLocal": "所有資料都保存在您的裝置上", "settingsAppearance": "外觀", "settingsApplicationTheme": "應用程式主題", "settingsColorPick": "選取顏色", "settingsColorReset": "重設", "settingsDbOperationInProgress": "資料庫操作正在進行中...", "settingsDirectories": "目錄", "settingsDirectoriesTracksCount": "{count} 首曲目", "settingsDirectoryRemoved": "目錄已移除", "settingsDirRemove": "移除", "settingsDirRescan": "重新掃描", "settingsDisplayVolumeSlider": "在播放器內顯示音量滑桿", "settingsGrantDirectoryAccess": "您需要允許應用程式存取該目錄,透過瀏覽器權限,以便它可以掃描其內容", "settingsImportTracks": "匯入曲目", "settingsInstallAppDesktop": "桌面版", "settingsInstallAppExplanation": "將 Snae 播放器新增到您的 {device} 以獲得更沉浸式的體驗", "settingsInstallAppHomeAction": "安裝", "settingsInstallAppHomeScreen": "主畫面", "settingsLanguage": "語言", "settingsMissingFs1": "您的瀏覽器不支援所需的 ", "settingsMissingFs2": "檔案系統功能,", "settingsMissingFs3": " 為了使此應用程式正常工作,", "settingsMissingFs4": "每個音樂檔案都必須複製並保存在應用程式儲存空間中,", "settingsMissingFs5": " 這可能會佔用您裝置的大量磁碟空間。", "settingsMotion": "動畫", "settingsMotionAuto": "自動", "settingsMotionNormal": "正常", "settingsMotionReduced": "減少", "settingsPlaybackSpeed": "播放速度", "settingsPlaybackSpeedReset": "重設速度", "settingsPreparingForScan": "準備掃描", "settingsPreservePitch": "保持音高", "settingsPreservePitchInfo": "在變更播放速度時,保持人聲與樂器的原始音高不變。", "settingsPrimaryColor": "應用程式主色", "settingsScanInProgress": "正在掃描曲目。{current}/{total}", "settingsScanNewOrUpdatedTracks": "找到 {newTracks} 首新的或更新的曲目", "settingsScanNoNewTracks": "未找到新的曲目", "settingsThemeAuto": "自動", "settingsThemeDark": "深色", "settingsThemeLight": "淺色", "settingsTracksInAppStorageTooltip": "這包含儲存在應用程式儲存空間中的曲目和/或您從 Snae Player v1 遷移的資料", "settingsTracksInsideAppMemory": "應用程式儲存空間中的曲目", "shuffle": "隨機播放", "successfullyRemovedTracks": "成功移除 {count} 首曲目", "track": "曲目", "trackAddToFavorites": "新增到收藏夾", "trackPlay": "播放 {name}", "trackRemoveFromFavorites": "從收藏夾中移除", "tracks": "曲目", "trackViewAlbum": "檢視專輯", "trackViewArtist": "檢視藝術家", "understood": "明白", "unknown": "未知", "validationMaxLength": "最多允許 {max} 個字元", "validationMinLength": "至少需要 {min} 個字元", "validationRequired": "必填欄位", "year": "年份" } ================================================ FILE: netlify.toml ================================================ [build] publish = "build/" command = "pnpm run build" [build.environment] NODE_VERSION = "24.15.0" # V1 version of the app used different service worker file name [[redirects]] from = "/sw.js" to = "/service-worker.js" status = 200 force = true [[redirects]] from = "/*" to = "/200.html" status = 200 [[headers]] for = "/*" [headers.values] Referrer-Policy = "strict-origin-when-cross-origin" X-Content-Type-Options = "nosniff" X-Frame-Options = "DENY" X-XSS-Protection = "1; mode=block" Content-Security-Policy = "frame-ancestors 'none'" Cross-Origin-Resource-Policy = "same-origin" Cross-Origin-Embedder-Policy = "require-corp" [[headers]] for = "/manifest.webmanifest" [headers.values] Content-Type = "application/manifest+json; charset=UTF-8" ================================================ FILE: package.json ================================================ { "name": "local-music-pwa-next", "version": "0.0.1", "private": true, "type": "module", "sideEffects": false, "scripts": { "prepare": "svelte-kit sync", "dev": "vite dev", "build": "vite build", "preview": "vite preview", "type-check": "svelte-kit sync && svelte-check", "i18n-check": "node scripts/check-translations.ts", "biome-check": "biome check .", "biome-fix": "biome check . --write", "prettier-check": "prettier --check ./src", "prettier-fix": "prettier --write ./src", "compile-i18n": "paraglide-js compile --project ./project.inlang", "test": "vitest run", "gen-color-theme": "node scripts/gen-color-theme.ts", "knip": "knip" }, "devDependencies": { "@biomejs/biome": "2.4.14", "@inlang/paraglide-js": "2.18.0", "@resvg/resvg-js": "^2.6.2", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.59.0", "@tailwindcss/vite": "^4.2.4", "@types/node": "^24.12.2", "@types/wicg-file-system-access": "^2023.10.7", "fake-indexeddb": "^6.2.5", "happy-dom": "^20.9.0", "image-size": "^2.0.2", "knip": "^6.11.0", "prettier": "^3.8.3", "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.8.0", "svelte": "5.55.5", "svelte-check": "^4.4.7", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", "unplugin-auto-import": "^21.0.0", "vite": "8.0.10", "vitest": "^4.1.5" }, "dependencies": { "@material/material-color-utilities": "^0.4.0", "@tanstack/virtual-core": "^3.14.0", "idb": "^8.0.3", "music-metadata": "^11.12.3", "tiny-invariant": "^1.3.3", "weak-lru-cache": "^1.2.2" }, "engines": { "node": "24.15.0" }, "packageManager": "pnpm@11.0.3" } ================================================ FILE: patches/@material__material-color-utilities.patch ================================================ diff --git a/dynamiccolor/color_spec_2025.js b/dynamiccolor/color_spec_2025.js index 8bef961c7c6127c028b98ee3305270be5247a0c2..271597946422c58b91a54c1e1a748d30350ac015 100644 --- a/dynamiccolor/color_spec_2025.js +++ b/dynamiccolor/color_spec_2025.js @@ -18,7 +18,7 @@ import { Hct } from '../hct/hct.js'; import * as math from '../utils/math_utils.js'; import { ColorSpecDelegateImpl2021 } from './color_spec_2021.js'; import { ContrastCurve } from './contrast_curve.js'; -import { DynamicColor, extendSpecVersion } from './dynamic_color'; +import { DynamicColor, extendSpecVersion } from './dynamic_color.js'; import { ToneDeltaPair } from './tone_delta_pair.js'; import { Variant } from './variant.js'; /** diff --git a/index.d.ts b/index.d.ts index 0618f70eb5221eeed3cf7a2e5724940716490aa3..1c5b5d9af414afea61489898ce45e2c93f882e08 100644 --- a/index.d.ts +++ b/index.d.ts @@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js'; export * from './dynamiccolor/variant.js'; export * from './hct/cam16.js'; export * from './hct/hct.js'; +export * from './hct/hct_solver.js'; export * from './hct/viewing_conditions.js'; export * from './palettes/core_palette.js'; export * from './palettes/tonal_palette.js'; diff --git a/index.js b/index.js index 0fede2e15730083a6c54ebd8cb0c36a1f3109486..ae708d01b37849c30f84dc628884f696e6527a3a 100644 --- a/index.js +++ b/index.js @@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js'; export * from './dynamiccolor/variant.js'; export * from './hct/cam16.js'; export * from './hct/hct.js'; +export * from './hct/hct_solver.js'; export * from './hct/viewing_conditions.js'; export * from './palettes/core_palette.js'; export * from './palettes/tonal_palette.js'; diff --git a/package.json b/package.json index 30ca4ac79453a16cf6a124eb126575af26146a69..fbde7a1241338fb8bc250a24b383b64893b73dfb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "publishConfig": { "access": "public" }, + "sideEffects": false, "description": "Algorithms and utilities that power the Material Design 3 (M3) color system, including choosing theme colors from images and creating tones of colors; all in a new color space.", "keywords": [ "material", diff --git a/scheme/scheme_content.js b/scheme/scheme_content.js index e06c67bc68883b4a5210dc4241544ed79dec905c..29d62f7514f53d30402ee2c002801d90a2938e2f 100644 --- a/scheme/scheme_content.js +++ b/scheme/scheme_content.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A scheme that places the source color in `Scheme.primaryContainer`. diff --git a/scheme/scheme_expressive.js b/scheme/scheme_expressive.js index 43d05c6a9989566f2f300e8bf01fa4f1a8e120f0..baf7ca348f18ddc82e8d1f4df26161b70fbb578d 100644 --- a/scheme/scheme_expressive.js +++ b/scheme/scheme_expressive.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A Dynamic Color theme that is intentionally detached from the source color. diff --git a/scheme/scheme_fidelity.js b/scheme/scheme_fidelity.js index a7461cdd4b49a49e642cd1060fba95ea1b6c7a1e..602d1eec140103f4f9501d9a84238eece61416f9 100644 --- a/scheme/scheme_fidelity.js +++ b/scheme/scheme_fidelity.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A scheme that places the source color in `Scheme.primaryContainer`. diff --git a/scheme/scheme_fruit_salad.js b/scheme/scheme_fruit_salad.js index 87443afa8bb09d9c6f67be12fde55b7db9b9f37a..aeaff555801de637d3b8005eed30718d88c362df 100644 --- a/scheme/scheme_fruit_salad.js +++ b/scheme/scheme_fruit_salad.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A playful theme - the source color's hue does not appear in the theme. diff --git a/scheme/scheme_monochrome.js b/scheme/scheme_monochrome.js index 30ad712ad104e397788ff3d8e11ea28ca00c0533..d18d656c4f98bbd4a1340feeaeff390f9a8b83fc 100644 --- a/scheme/scheme_monochrome.js +++ b/scheme/scheme_monochrome.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** A Dynamic Color theme that is grayscale. */ export class SchemeMonochrome extends DynamicScheme { diff --git a/scheme/scheme_neutral.js b/scheme/scheme_neutral.js index 0d03a5e0f0200feb471860b48d5243dd5da5429d..e4cffaaa9d66bd056fded64bfb0ce43341789c66 100644 --- a/scheme/scheme_neutral.js +++ b/scheme/scheme_neutral.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** A Dynamic Color theme that is near grayscale. */ export class SchemeNeutral extends DynamicScheme { diff --git a/scheme/scheme_rainbow.js b/scheme/scheme_rainbow.js index 65e6d3fd934ed07a2e6efdf6c552c306873d26e2..f80be9ae6da0d181c18a064ca4c645835abf9f86 100644 --- a/scheme/scheme_rainbow.js +++ b/scheme/scheme_rainbow.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A playful theme - the source color's hue does not appear in the theme. diff --git a/scheme/scheme_tonal_spot.js b/scheme/scheme_tonal_spot.js index 6c506c3c23279e5842757b991cd066ef266fceea..c48f498c41e0cde98f34c3d7394a75ef1bf764cd 100644 --- a/scheme/scheme_tonal_spot.js +++ b/scheme/scheme_tonal_spot.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A Dynamic Color theme with low to medium colorfulness and a Tertiary diff --git a/scheme/scheme_vibrant.js b/scheme/scheme_vibrant.js index cba1172e7d7ccae62d03e635c38063153bc5a61a..3a37230ad4b589c7d3071dbb093335eab0d71f4c 100644 --- a/scheme/scheme_vibrant.js +++ b/scheme/scheme_vibrant.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DynamicScheme } from '../dynamiccolor/dynamic_scheme'; +import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js'; import { Variant } from '../dynamiccolor/variant.js'; /** * A Dynamic Color theme that maxes out colorfulness at each position in the ================================================ FILE: pnpm-workspace.yaml ================================================ engineStrict: true allowBuilds: '@biomejs/biome': false '@tailwindcss/oxide': false patchedDependencies: '@material/material-color-utilities': 'patches/@material__material-color-utilities.patch' ================================================ FILE: project.inlang/settings.json ================================================ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", "locales": ["en", "lt", "de", "fr", "zh-CN", "zh-TW"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js" ], "plugin.inlang.messageFormat": { "pathPattern": "./messages/{languageTag}.json" } } ================================================ FILE: scripts/check-translations.ts ================================================ import projectSettings from '../project.inlang/settings.json' with { type: 'json' } type Messages = Record interface LocaleIssues { missingKeys: string[] paramMismatches: string[] } interface LocaleReport { locale: string issues: LocaleIssues } interface BaseMessageWithParams { value: string params: string[] } const extractParams = (value: string): string[] => { const paramsRegex = /{(\w+)}/g const params: string[] = [] const matches = value.matchAll(paramsRegex) for (const match of matches) { params.push(match[1]) } return params } const getMessages = async (locale: string): Promise => { const module = (await import(`../messages/${locale}.json`, { with: { type: 'json' } })) as { default: Messages } delete module.default.$schema return module.default } const checkLocale = async (locale: string, baseMessagesMap: Map) => { const messages = await getMessages(locale) const issues: LocaleIssues = { missingKeys: [], paramMismatches: [], } for (const [key, baseData] of baseMessagesMap) { if (key in messages) { const localeParams = extractParams(messages[key]) const missingParams = baseData.params.filter((param) => !localeParams.includes(param)) const extraParams = localeParams.filter((param) => !baseData.params.includes(param)) if (missingParams.length > 0) { issues.paramMismatches.push(key) } else if (extraParams.length > 0) { issues.paramMismatches.push(key) } } else { issues.missingKeys.push(key) } } return { locale, issues, } } const printReport = (reports: LocaleReport[]) => { let hasAnyIssues = false for (const report of reports) { const { locale, issues } = report if (issues.paramMismatches.length === 0 && issues.missingKeys.length === 0) { console.info(`✅ Locale "${locale}" has no issues`) } else { hasAnyIssues = true } if (issues.missingKeys.length > 0) { console.info(`❌ "${locale}" missing keys:`) console.info(issues.missingKeys) } if (issues.paramMismatches.length > 0) { console.info(`❌ "${locale}" has param mismatches in keys:`) console.info(issues.paramMismatches) } } if (hasAnyIssues) { process.exit(1) } else { process.exit(0) } } const baseMessages = await getMessages(projectSettings.baseLocale) const baseMessagesMap = new Map() for (const [key, value] of Object.entries(baseMessages)) { baseMessagesMap.set(key, { value, params: extractParams(value), }) } const reports: LocaleReport[] = [] for (const locale of projectSettings.locales) { if (locale !== projectSettings.baseLocale) { const report = await checkLocale(locale, baseMessagesMap) reports.push(report) } } printReport(reports) ================================================ FILE: scripts/gen-color-theme.ts ================================================ import { writeFileSync } from 'node:fs' import { argbFromHex, // biome-ignore lint/style/noRestrictedImports: Used for static theme generation } from '@material/material-color-utilities' import { getThemePaletteRgbEntries } from '../src/lib/theme.ts' const defaultColorSeed = '#cc9724' const outputFile = `${import.meta.dirname}/../src/theme-colors.css` const argb = argbFromHex(defaultColorSeed) const tokensLightEntries = getThemePaletteRgbEntries(argb, false) const tokensDark = Object.fromEntries(getThemePaletteRgbEntries(argb, true)) const variables = tokensLightEntries .map(([name, lightValue]) => `--color-${name}: light-dark(${lightValue}, ${tokensDark[name]});`) .join('\n ') const content = `/* This file is auto generated, do not edit manually. */ @theme { --color-*: initial; --color-transparent: transparent; --color-current: currentColor; ${variables} } ` writeFileSync(outputFile, content, { encoding: 'utf-8', }) ================================================ FILE: src/ambient.d.ts ================================================ declare module '*?as=metadata' { const metadata: { src: string width: number height: number } export default metadata } ================================================ FILE: src/app.css ================================================ @import 'tailwindcss'; @import './theme-colors.css'; /* We don't use these classes */ @source not inline('container'); /* latin */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: swap; src: url('/fonts/Heebo.latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* latin-ext */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: swap; src: url('/fonts/Heebo.latin-ext.woff2') format('woff2'); unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* cyrillic */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: swap; src: url('/fonts/Heebo.cyrillic.woff2') format('woff2'); unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* cyrillic-ext */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: swap; src: url('/fonts/Heebo.cyrillic-ext.woff2') format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* greek */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: swap; src: url('/fonts/Heebo.greek.woff2') format('woff2'); unicode-range: U+0370-03FF; } /* greek-ext */ @font-face { font-family: 'Heebo'; font-style: normal; font-display: fallback; src: url('/fonts/Heebo.greek-ext.woff2') format('woff2'); unicode-range: U+1F00-1FFF; } @font-face { font-family: 'App CJK Fallback'; src: local('Noto Sans SC'), local('Noto Sans CJK SC'), local('Noto Sans TC'), local('Noto Sans CJK TC'), local('Noto Sans HK'), local('Noto Sans CJK HK'), local('PingFang SC'), local('PingFang TC'), local('Microsoft YaHei'), local('Microsoft JhengHei'); font-style: normal; font-weight: 400 900; font-display: swap; unicode-range: U+3000-303F, /* CJK punctuation */ U+3400-4DBF, /* CJK Ext A */ U+4E00-9FFF, /* CJK Unified Ideographs */ U+F900-FAFF, /* CJK Compatibility Ideographs */ U+FF00-FFEF; /* Half/Fullwidth */ } @theme { --breakpoint-xs: 24rem; --breakpoint-xss: 20rem; --ease-*: initial; --ease-standard: cubic-bezier(0.2, 0, 0, 1); --ease-outgoing40: cubic-bezier(0.4, 0, 1, 1); --ease-incoming80: cubic-bezier(0, 0, 0.2, 1); --ease-incoming80outgoing40: cubic-bezier(0.4, 0, 0.2, 1); --font-sans: 'Heebo', 'App CJK Fallback', system-ui, 'Noto Color Emoji', sans-serif; --text-*: initial; } html { background-color: var(--color-surface); color: var(--color-onSurface); font-family: var(--font-sans); font-optical-sizing: auto; color-scheme: light; width: 100%; overflow-y: scroll; overflow-x: hidden; touch-action: manipulation; -webkit-touch-callout: none; /* Disable the iOS popup when long-press on a link */ /* Chrome implementation with fixed elements is very buggy */ /* scrollbar-gutter: stable; */ scroll-padding-top: var(--app-header-height); scroll-padding-bottom: var(--bottom-overlay-height); --app-header-height: --spacing(16); --app-max-content-width: --spacing(400); --mktg-content-max-width: --spacing(300); } html.dark { color-scheme: dark; } html, body { min-height: 100dvh; } body { -webkit-tap-highlight-color: transparent; display: flex; flex-direction: column; /* Needed for Safari */ -webkit-user-select: none; user-select: none; @apply text-body-md; } #app { display: flex; flex-direction: column; flex-grow: 1; } source { display: none; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } @layer base { * { scrollbar-width: thin; scrollbar-color: --alpha(var(--color-secondary) / 30%) transparent; } :where(:focus) { outline: none; } :where(:focus-visible) { outline: --spacing(0.5) solid var(--color-onSurface); outline-offset: --spacing(0.5); } strong { font-weight: 600; } } @keyframes tooltip-fade-in { from { opacity: 0; } to { opacity: 1; } } .tooltip { position: fixed; position-area: bottom; position-try-fallbacks: top, left, right; margin: 8px; animation: tooltip-fade-in 0.3s ease-out; } @utility interactable { overflow: hidden; appearance: none; border: none; outline-width: 0; text-decoration: none; cursor: pointer; display: flex; align-items: center; z-index: 0; position: relative; transition: outline-width 150ms linear; &::after { display: none; content: ''; position: absolute; height: 100%; width: 100%; left: 0; top: 0; background: currentColor; z-index: -1; pointer-events: none; opacity: 0; transition: opacity 0.2s linear, display 0.2s allow-discrete; } @media (any-hover: hover) { &:hover::after { display: block; opacity: 0.08; } &[disabled]::after { display: none; } } &:is(:focus-visible), &:hover:focus-visible { outline-width: --spacing(0.5); } &:focus-visible::after { display: block; opacity: 0.12; } @starting-style { &::after { opacity: 0 !important; } } } @utility flip-x { transform: scaleX(-1); } @utility flip-y { transform: scaleY(-1); } @utility text-headline-lg { font-size: --spacing(8); line-height: --spacing(10); letter-spacing: 0; font-weight: 700; } @utility text-headline-md { font-size: --spacing(7); line-height: --spacing(9); letter-spacing: 0; font-weight: 400; } @utility text-headline-sm { font-size: --spacing(6); line-height: --spacing(8); letter-spacing: 0; font-weight: 400; } @utility text-title-lg { font-size: --spacing(5.5); line-height: --spacing(7); letter-spacing: 0; font-weight: 500; } @utility text-title-md { font-size: --spacing(4); line-height: --spacing(6); letter-spacing: 0.15px; font-weight: 500; } @utility text-title-sm { font-size: --spacing(3.5); line-height: --spacing(5); letter-spacing: 0.1px; font-weight: 500; } @utility text-label-lg { font-size: --spacing(3.5); line-height: --spacing(5); letter-spacing: 0.1px; font-weight: 500; } @utility text-label-md { font-size: 12px; line-height: --spacing(4); letter-spacing: 0.5px; font-weight: 500; } @utility text-label-sm { font-size: 11px; line-height: --spacing(4); letter-spacing: 0.5px; font-weight: 500; } @utility text-body-lg { font-size: --spacing(4); line-height: --spacing(6); letter-spacing: 0.15px; font-weight: 400; } @utility text-body-md { font-size: --spacing(3.5); line-height: --spacing(5); letter-spacing: 0.25px; font-weight: 400; } @utility text-body-sm { font-size: --spacing(3); line-height: --spacing(4); letter-spacing: 0.4px; font-weight: 400; } @utility view-name-* { view-transition-name: --value([*]); } @utility scrollbar-gutter-stable { scrollbar-gutter: stable; } @utility stack-in-grid { grid-area: 1 / 1; } @custom-variant active-view-player { html:active-view-transition-type(player) & { @slot; } } @custom-variant active-view-regular { html:active-view-transition-type(regular) & { @slot; } } @layer components { .link { text-decoration: underline; } .mktg-content-width { width: 100%; max-width: var(--mktg-content-max-width); padding-left: --spacing(6); padding-right: --spacing(6); margin-left: auto; margin-right: auto; align-items: center; display: flex; flex-direction: column; } .mktg-content-width-using-grid { display: grid; grid-template-columns: minmax(0, var(--mktg-content-max-width)); justify-content: center; } .card { background-color: var(--color-surfaceContainer); display: flex; flex-direction: column; border-radius: --spacing(2); color: var(--color-onSurface); } .virtual-item { position: absolute !important; contain: strict; will-change: transform; } .ripple { width: --spacing(1); height: --spacing(1); position: absolute; border-radius: 50%; opacity: 0.2; background-color: currentColor; animation-fill-mode: both; contain: strict; will-change: transform, opacity; pointer-events: none; } } /* Hide Netlify preview bar */ div[data-netlify-deploy-id] { display: none; } ================================================ FILE: src/app.d.ts ================================================ import type { Snippet as SnippetInternal } from 'svelte' import type { ClassValue as ClassValueInternal } from 'svelte/elements' declare module '$env/static/public' { const PUBLIC_FALLBACK_PAGE: string const PUBLIC_GOAT_COUNTER_URL: string } declare global { namespace App { // interface Error {} // interface Locals {} interface PageData { noPlayerOverlay?: boolean htmlOverflow?: 'auto' | 'default' } // interface Platform {} } // Not using unplugin auto import because because when used getting error: // Exported variable 'Foo' has or is using private name 'ParentChild' type ClassValue = ClassValueInternal type Snippet = SnippetInternal interface Navigator { // Optional because Safari and Firefox don't support it userAgentData?: { mobile: boolean platform: 'macOS' | 'Windows' | (string & {}) brands: { brand: string version: string }[] } } /** * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler * before a user is prompted to "install" a web site to a home screen on mobile. */ interface BeforeInstallPromptEvent extends Event { /** * Returns an array of DOMString items containing the platforms on which the event was dispatched. * This is provided for user agents that want to present a choice of versions to the user such as, * for example, "web" or "play" which would allow the user to chose between a web version or * an Android version. */ readonly platforms: string[] /** * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed". */ readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' platform: string }> /** * Allows a developer to show the install prompt at a time of their own choosing. * This method returns a Promise. */ prompt: () => Promise } interface WindowEventMap { beforeinstallprompt: BeforeInstallPromptEvent } interface GoatCounter { count: (data: { path: string; title?: string; event?: boolean }) => void } interface Window { /** Analytics. If ad blocker blocks it this will be undefined */ goatcounter?: GoatCounter } // All modern browsers use PointerEvent instead of MouseEvent for // click, dblclick, and contextmenu. Since we can't change global // type easily we just add missing properties to MouseEvent to make it compatible with PointerEvent. interface MouseEvent { pointerType: 'mouse' | 'pen' | 'touch' } } ================================================ FILE: src/app.html ================================================ %snae.theme-color-meta% Snae Player %sveltekit.head% %snae.svg-icons-paths%
%sveltekit.body%
================================================ FILE: src/hooks.server.ts ================================================ import type { Handle } from '@sveltejs/kit' import { APP_DESCRIPTION_EN } from '$lib/app-metadata.ts' import { ICON_PATHS } from '$lib/components/icon/icon-paths.server.ts' import { PUBLIC_FALLBACK_PAGE, PUBLIC_GOAT_COUNTER_URL } from '$env/static/public' import { THEME_PALLETTE_DARK, THEME_PALLETTE_LIGHT } from './server/theme-colors.ts' const getThemeColorMeta = (color: string | undefined, theme: 'dark' | 'light') => `` const replaceThemeColorMeta = (html: string) => html.replace( '%snae.theme-color-meta%', ` ${getThemeColorMeta(THEME_PALLETTE_LIGHT.surface, 'light')} ${getThemeColorMeta(THEME_PALLETTE_DARK.surface, 'dark')} `, ) const getSvgSymbol = (name: string, path: string) => ` ` const replaceSvgIconPaths = (html: string) => { const icons = Object.entries(ICON_PATHS) // Instead of keeping the icons paths in the client js bundle, we can inline them in the html // making loading tiny bit faster return html.replace( '%snae.svg-icons-paths%', ` `, ) } const replaceGoatCounterUrl = (html: string) => html.replaceAll('%snae.goat-counter-url%', PUBLIC_GOAT_COUNTER_URL) const replaceDescription = (html: string) => html.replace('%snae.description%', APP_DESCRIPTION_EN) const transformPageChunk = ({ html }: { html: string }) => { html = replaceSvgIconPaths(html) html = replaceThemeColorMeta(html) html = replaceGoatCounterUrl(html) html = replaceDescription(html) return html } // This will only run in dev/preview or build and not in production // since we are using the static adapter export const handle: Handle = async ({ event, resolve }) => { // Adding this so service-worker can properly cache the 200.html if (event.url.pathname === PUBLIC_FALLBACK_PAGE) { const response = await resolve(event, { transformPageChunk }) return new Response(response.body, { status: 200, headers: response.headers, }) } return resolve(event, { transformPageChunk }) } ================================================ FILE: src/lib/app-metadata.ts ================================================ export const APP_NAME_EN = 'Snae Player' export const APP_NAME_SHORT_EN = 'Snae' export const APP_DESCRIPTION_EN = 'Play your local music in the browser with playlists, equalizer, playback speed, and offline listening. No uploads, no sign-up.' ================================================ FILE: src/lib/attachments/ripple.ts ================================================ import type { Attachment } from 'svelte/attachments' import { on } from 'svelte/events' import { assign } from '$lib/helpers/utils/assign.ts' import { animateEmpty } from '../helpers/animations.ts' const FADE_DURATION = 180 const SCALE_DURATION = 400 const createRippleSpan = () => { if (import.meta.env.SSR) { return null as unknown as HTMLSpanElement } const span = document.createElement('span') span.className = 'ripple' return span } const rippleSpan = createRippleSpan() const activeRipples = new Map() /** @public */ export const getActiveRipplesCount = (): number => activeRipples.size const markForOrExitRipple = (ripple: HTMLSpanElement) => { const canExit = activeRipples.get(ripple) if (canExit) { const fadeAni = ripple.animate( { opacity: 0 }, { duration: FADE_DURATION, easing: 'linear', }, ) fadeAni.finished.then(() => { activeRipples.delete(ripple) ripple.remove() }) } else { activeRipples.set(ripple, true) } } const onExitHandler = () => { if (activeRipples.size === 0) { return } for (const ripple of activeRipples.keys()) { markForOrExitRipple(ripple) } } if (!import.meta.env.SSR) { document.addEventListener('pointercancel', onExitHandler, { passive: true }) document.addEventListener('pointerup', onExitHandler, { passive: true }) } const onPointerDownHandler = (e: PointerEvent) => { // Only respond to main click events. if (e.button !== 0) { return } const node = e.currentTarget as HTMLElement if (node.hasAttribute('disabled')) { return } const rect = node.getBoundingClientRect() const ripple = rippleSpan.cloneNode() as HTMLSpanElement // Use small value and scale it up to the right size, // because that way less GPU memory is used // when container is very big. const realDiameter = 4 const realRadius = realDiameter / 2 const posX = e.clientX - rect.left const posY = e.clientY - rect.top assign(ripple.style, { top: `${posY - realRadius}px`, left: `${posX - realRadius}px`, }) activeRipples.set(ripple, false) node.appendChild(ripple) // Find absolute distance from center of the click // to the edge of the container. const distanceToCX = Math.max(posX, rect.width - posX) const distanceToCY = Math.max(posY, rect.height - posY) const distanceC = Math.max(distanceToCX, distanceToCY) // Place square inside the container so it fills all available space, // then draw circle around it. This is basic idea of this calculation. const squareSide = distanceC * 2 const diameter = Math.sqrt(squareSide ** 2 * 2) const scaleValue = diameter / realDiameter ripple.animate( { transform: ['scale(0)', `scale(${scaleValue})`] }, { duration: SCALE_DURATION, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'both', }, ) animateEmpty(ripple, SCALE_DURATION - FADE_DURATION).finished.then(() => markForOrExitRipple(ripple), ) } export interface RippleOptions { stopPropagation?: boolean } export const ripple = (options: RippleOptions = {}): Attachment => (node) => { const cleanup = on(node, 'pointerdown', (e) => { if (options?.stopPropagation) { e.stopPropagation() } onPointerDownHandler(e) }) return cleanup } ================================================ FILE: src/lib/attachments/tooltip.ts ================================================ import type { Attachment } from 'svelte/attachments' import { on } from 'svelte/events' import { browser } from '$app/environment' let tooltipTemplate: HTMLDivElement | null = null const cloneTooltipTemplate = () => { if (tooltipTemplate === null) { tooltipTemplate = document.createElement('div') tooltipTemplate.setAttribute('role', 'tooltip') tooltipTemplate.className = 'tooltip bg-inverseSurface max-w-80 flex items-center m-0 text-body-sm min-h-6 text-inverseOnSurface px-2 py-0.5 rounded-sm' tooltipTemplate.popover = 'manual' } return tooltipTemplate.cloneNode() as HTMLDivElement } const supportsCssAnchor = browser && CSS.supports('anchor-name', '--a') let tooltipCounter = 0 export const tooltip = (message: string | undefined): Attachment => { tooltipCounter += 1 const anchorName = `--tooltip-${tooltipCounter}` return (target) => { if (!message || import.meta.env.SSR || !supportsCssAnchor) { return } target.setAttribute('title', message) let tooltipElement: HTMLElement | null = null let timeoutId: number | null = null const controller = new AbortController() const { signal } = controller const clearTooltipTimeout = () => { if (timeoutId) { window.clearTimeout(timeoutId) timeoutId = null } } const showTooltip = () => { if (tooltipElement || !message) { return } // Remove attribute to prevent default browser tooltip target.removeAttribute('title') target.style.anchorName = anchorName tooltipElement = cloneTooltipTemplate() tooltipElement.textContent = message tooltipElement.style.positionAnchor = anchorName document.body.appendChild(tooltipElement) tooltipElement.showPopover() } const scheduleShowTooltip = () => { if (tooltipElement) { return } timeoutId = window.setTimeout(showTooltip, 300) } const hideTooltip = () => { clearTooltipTimeout() // Restore the title attribute if (message) { target.setAttribute('title', message) } target.style.removeProperty('anchor-name') if (tooltipElement) { tooltipElement.remove() tooltipElement = null } } on(target, 'pointerenter', scheduleShowTooltip, { signal }) on( target, 'focusin', () => { if (target.matches(':focus-visible')) { scheduleShowTooltip() } }, { signal }, ) on(target, 'pointerleave', hideTooltip, { signal }) // Makes so tooltip is hidden just before view transitions starts on(target, 'pointerup', hideTooltip, { signal }) on(target, 'focusout', hideTooltip, { signal }) // Needed for Safari on(target, 'touchend', hideTooltip, { signal }) const cleanup = () => { controller.abort() hideTooltip() } return cleanup } } ================================================ FILE: src/lib/components/AlbumsListContainer.svelte ================================================ {#snippet item(album)}
{formatNameOrUnknown(album.name)}
{formatArtists(album.artists)}
{/snippet}
================================================ FILE: src/lib/components/ArtistListContainer.svelte ================================================ {#snippet item(artist)}
{formatNameOrUnknown(artist.name)}
{/snippet}
================================================ FILE: src/lib/components/Artwork.svelte ================================================
{#if src && !error} { error = true }} onload={() => { error = false }} /> {:else if fallbackIcon !== false} {/if} {#if children} {@render children()} {/if}
================================================ FILE: src/lib/components/BackButton.svelte ================================================ ================================================ FILE: src/lib/components/Button.svelte ================================================ {#if children} {@render children()} {/if} ================================================ FILE: src/lib/components/FavoriteButton.svelte ================================================ ================================================ FILE: src/lib/components/Header.svelte ================================================ {#if isFixed} {/if}
{#if !noBackButton} {/if} {#if title}
{title}
{/if} {@render children?.()}
================================================ FILE: src/lib/components/IconButton.svelte ================================================ ================================================ FILE: src/lib/components/ListDetailsLayout.svelte ================================================
{#if isBothMode} {@render list(mode)} {/if}
{#if isBothMode || mode === 'details'} {@render details(mode)} {:else if mode === 'list'} {@render list(mode)} {/if}
================================================ FILE: src/lib/components/ListItem.svelte ================================================
{ if (e.key === 'Enter') { clickHandler(e) } }} {oncontextmenu} > {@render children()}
================================================ FILE: src/lib/components/MenuButton.svelte ================================================ {#if menuItems} { e.stopPropagation() menu.showFromEvent(e, typeof menuItems === 'function' ? menuItems() : menuItems, { anchor: true, width, preferredAlignment: alignment, }) }} /> {/if} ================================================ FILE: src/lib/components/PlayerOverlay.svelte ================================================
{#if mainStore.volumeSliderEnabled} {/if}
================================================ FILE: src/lib/components/ScrollContainer.svelte ================================================
{@render children()}
================================================ FILE: src/lib/components/Select.svelte ================================================ ================================================ FILE: src/lib/components/Separator.svelte ================================================ ================================================ FILE: src/lib/components/Slider.svelte ================================================
{ if (!vertical) { trackSize = width } } } bind:clientHeight={ null, (height: number) => { if (vertical) { trackSize = height } } } > { onSeekStart?.() }} onpointerup={() => { onSeekEnd?.() }} ontouchstart={() => { onSeekStart?.() }} ontouchend={() => { onSeekEnd?.() }} />
================================================ FILE: src/lib/components/Spinner.svelte ================================================ ================================================ FILE: src/lib/components/Switch.svelte ================================================
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggle() } }} >
================================================ FILE: src/lib/components/Tabs.svelte ================================================
{#each items as item, index} {/each}
================================================ FILE: src/lib/components/TextField.svelte ================================================
{ input.setCustomValidity(validationIssue ? ' ' : '') }} />
================================================ FILE: src/lib/components/VirtualContainer.svelte ================================================ {#if count === 0}
{m.noItemsToDisplay()}
{:else}
{#each virtualizer.getVirtualItems() as virtualItem (key(virtualItem.index))} {@render children(virtualItem)} {/each}
{/if} ================================================ FILE: src/lib/components/WrapTranslation.svelte ================================================
{#each parts as part} {#if part.startsWith(valueMarker)} {@render props[part.replaceAll(valueMarker, '')]?.()} {:else} {part} {/if} {/each}
================================================ FILE: src/lib/components/animated-icons/PlayPauseIcon.svelte ================================================
================================================ FILE: src/lib/components/animated-icons/PlayPreviousNextIcon.svelte ================================================
================================================ FILE: src/lib/components/dialog/CommonDialog.svelte ================================================ {#snippet children({ data, close })}
{ e.preventDefault() onsubmit?.(e, data) }} > {#if externalChildren}
{@render externalChildren({ data, close })}
{/if} {/snippet}
================================================ FILE: src/lib/components/dialog/Dialog.svelte ================================================ {#if state.isOpen} { if (e.key === 'Escape') { close() // We don't want dialog to exit top level // and instead remain until the animation is complete // and then remove from the DOM e.preventDefault() } }} onclose={() => { // There is no way to prevent dialog close event close() }} class={[ 'm-auto flex flex-col rounded-3xl bg-surfaceContainerHigh text-onSurface contain-content select-none focus:outline-none', className, ]} > {#if header} {@render header({ data: state.data, close })} {:else}
{#if icon} {/if} {#if titleText}
{titleText}
{/if}
{/if}
{@render children?.({ data: state.data, close, })}
{/if} ================================================ FILE: src/lib/components/dialog/DialogFooter.svelte ================================================ {#if buttons?.length}
{#each buttons as button} {/each}
{/if} ================================================ FILE: src/lib/components/global-dialogs/EqualizerDialog.svelte ================================================ {#snippet header()}
{m.equalizerTitle()}
{/snippet} {#snippet children({ close })}
{#each presets as [preset, label]} {/each}
{#each EQ_BANDS as band, i} {@const gain = eq.bands[i] ?? 0}
{gain > 0 ? '+' : ''}{Math.round(gain)}
gain, (v) => eq.setBand(i, v)} disabled={!eq.enabled} />
{band.label}
{/each}
{/snippet}
================================================ FILE: src/lib/components/global-dialogs/RemoveFromLibraryDialog.svelte ================================================ { if (data.type === 'multiple') { return m.libraryConfirmRemoveMultipleTitle({ count: data.ids.length, }) } return m.libraryConfirmRemoveTitle({ name: truncate(data.name ?? '', 10), }) }} buttons={[ { title: m.libraryCancel(), }, { title: m.libraryRemove(), type: 'submit', }, ]} onsubmit={(_, data) => { open.close() if (data.type === 'multiple') { void removeMultiple(data.storeName, data.ids) return } void removeSingle(data.storeName, data.id) }} /> ================================================ FILE: src/lib/components/global-dialogs/dialogs.ts ================================================ import type { Component } from 'svelte' import type { DialogOpenAccessor } from '../dialog/Dialog.svelte' import EqualizerDialog from './EqualizerDialog.svelte' import AddToPlaylistDialog from './playlists/AddToPlaylistDialog.svelte' import EditPlaylistDialog from './playlists/EditPlaylistDialog.svelte' import NewPlaylistDialog from './playlists/NewPlaylistDialog.svelte' import RemoveFromLibraryDialog from './RemoveFromLibraryDialog.svelte' // biome-ignore lint/suspicious/noExplicitAny: needed for inference type ComponentWithOpenProp = Component<{ open: DialogOpenAccessor }> export const APP_DIALOGS_COMPONENTS_MAP = { equalizer: EqualizerDialog, removeFromLibrary: RemoveFromLibraryDialog, addToPlaylist: AddToPlaylistDialog, newPlaylist: NewPlaylistDialog, editPlaylist: EditPlaylistDialog, } satisfies Record export type AppDialogKey = keyof typeof APP_DIALOGS_COMPONENTS_MAP export const APP_DIALOGS_KEYS = Object.keys(APP_DIALOGS_COMPONENTS_MAP) as AppDialogKey[] ================================================ FILE: src/lib/components/global-dialogs/playlists/AddToPlaylistDialog.svelte ================================================ {#snippet children({ data: trackIds, close })} { snackbar.unexpectedError(e) queueMicrotask(() => { close() }) }} > {#snippet children({ save })} { dialogs.openDialog('newPlaylist') }, }, { title: m.libraryCancel(), }, { title: m.librarySave(), action: save, }, ]} onclose={close} /> {/snippet} {/snippet} ================================================ FILE: src/lib/components/global-dialogs/playlists/AddToPlaylistDialogContent.svelte ================================================
{ toggleSelection(item.playlist.id) }} > {#snippet icon(playlist)} {@const isInPlaylist = isTrackInPlaylist(playlist.id)}
{#if isInPlaylist} {/if}
{/snippet}
{@render children({ save })} ================================================ FILE: src/lib/components/global-dialogs/playlists/EditPlaylistDialog.svelte ================================================ {#snippet children({ data })} {/snippet} ================================================ FILE: src/lib/components/global-dialogs/playlists/NewPlaylistDialog.svelte ================================================ ================================================ FILE: src/lib/components/icon/Icon.svelte ================================================ ================================================ FILE: src/lib/components/icon/icon-paths.server.ts ================================================ // Icons taken from https://pictogrammers.com/library/mdi/ // and then minified using https://jakearchibald.github.io/svgomg/ export const ICON_PATHS = { addPlaylist: 'M2 16h8v-2H2m16 0v-4h-2v4h-4v2h4v4h2v-4h4v-2m-8-8H2v2h12m0 2H2v2h12v-2z', album: 'M12 11a1 1 0 00-1 1 1 1 0 001 1 1 1 0 001-1 1 1 0 00-1-1m0 5.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5M12 2A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2z', alertCircle: 'M11 15h2v2h-2v-2m0-8h2v6h-2V7m1-5C6.47 2 2 6.5 2 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2m0 18a8 8 0 01-8-8 8 8 0 018-8 8 8 0 018 8 8 8 0 01-8 8z', backArrow: 'M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z', cached: 'M19,8L15,12H18A6,6 0 0,1 12,18C11,18 10.03,17.75 9.2,17.3L7.74,18.76C8.97,19.54 10.43,20 12,20A8,8 0 0,0 20,12H23M6,12A6,6 0 0,1 12,6C13,6 13.97,6.25 14.8,6.7L16.26,5.24C15.03,4.46 13.57,4 12,4A8,8 0 0,0 4,12H1L5,16L9,12', cellphone: 'M17 19H7V5h10m0-4H7c-1.11 0-2 .89-2 2v18c0 1.11.89 2 2 2h10c1.11 0 2-.89 2-2V3c0-1.11-.89-2-2-2Z', check: 'M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59 21 7Z', chevronRight: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z', chevronUp: 'M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z', close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z', delete: 'M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 002 2h8a2 2 0 002-2V7H6v12z', discord: 'M19.952 5.672c-1.904-1.531-4.916-1.79-5.044-1.801a.477.477 0 0 0-.474.281 3.715 3.715 0 0 0-.145.398c1.259.212 2.806.64 4.206 1.509a.48.48 0 0 1-.505.813C15.584 5.38 12.578 5.305 12 5.305s-3.585.075-5.989 1.567a.479.479 0 1 1-.505-.813c1.4-.868 2.946-1.297 4.206-1.509-.074-.236-.14-.386-.145-.398a.473.473 0 0 0-.475-.28c-.127.01-3.139.269-5.069 1.822C3.015 6.625 1 12.073 1 16.783a.48.48 0 0 0 .063.237c1.391 2.443 5.185 3.083 6.05 3.111h.015a.478.478 0 0 0 .387-.197l.875-1.202c-2.359-.61-3.564-1.645-3.634-1.706a.478.478 0 0 1 .632-.718c.029.026 2.248 1.909 6.612 1.909 4.372 0 6.591-1.891 6.613-1.91a.479.479 0 0 1 .632.718c-.07.062-1.275 1.096-3.634 1.706l.875 1.202c.09.124.234.197.387.197h.015c.865-.027 4.659-.667 6.05-3.111a.486.486 0 0 0 .062-.236c0-4.71-2.015-10.158-3.048-11.111zM8.891 14.87c-.924 0-1.674-.857-1.674-1.913s.749-1.913 1.674-1.913 1.674.857 1.674 1.913-.749 1.913-1.674 1.913zm6.218 0c-.924 0-1.674-.857-1.674-1.913s.749-1.913 1.674-1.913c.924 0 1.674.857 1.674 1.913s-.75 1.913-1.674 1.913z', dragHorizontal: 'M21 11H3V9H21V11M21 13H3V15H21V13Z', equalizer: 'M10,20H14V4H10V20M4,20H8V12H4V20M16,9V20H20V9H16Z', eyedropper: 'M6.92 19 5 17.08 13.06 9 15 10.94m5.71-5.31-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.05.01-1.42Z', favorite: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5 2 5.41 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.41 22 8.5c0 3.77-3.4 6.86-8.55 11.53L12 21.35z', favoriteOutline: 'M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z', flash: 'M7 2v11h3v9l7-12h-4l3-8H7Z', folder: 'M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.9-2-2-2h-8l-2-2Z', folderHidden: 'M9 4v4H6V4h3M4 16v-3H2v3h2m-2-4h2V9H2v3m16-4h4c0-1.11-.9-2-2-2h-2v2m4 5h-2v3h2v-3m-2-4v3h2V9h-2M9 20v-2H6v2h3m-4-2H4v-1H2v1c0 1.11.9 2 2 2h1v-2m15-1v1h-2v2h2c1.11 0 2-.89 2-2v-1h-2M4 8h1V4H4c-1.11 0-2 .89-2 2v2h2m13 10h-3v2h3v-2m-4 0h-3v2h3v-2m4-12h-3v2h3V6m-7 2h3V6h-1l-2-2v4Z', github: 'M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z', headphones: 'M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9Z', home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8h5Z', information: 'M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2Z', lock: 'M12 17a2 2 0 0 0 2-2c0-1.11-.89-2-2-2a2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3Z', lockCheck: 'M14 15c0 1.11-.89 2-2 2a2 2 0 0 1-2-2c0-1.11.89-2 2-2a2 2 0 0 1 2 2m-.91 5c.12.72.37 1.39.72 2H6a2 2 0 0 1-2-2V10c0-1.11.89-2 2-2h1V6c0-2.76 2.24-5 5-5s5 2.24 5 5v2h1a2 2 0 0 1 2 2v3.09c-.33-.05-.66-.09-1-.09-.34 0-.67.04-1 .09V10H6v10h7.09M9 8h6V6c0-1.66-1.34-3-3-3S9 4.34 9 6v2m12.34 7.84-3.59 3.59-1.59-1.59L15 19l2.75 3 4.75-4.75-1.16-1.41Z', magnify: 'M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27C12.59 15.41 11.11 16 9.5 16A6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z', menuDown: 'M7,10L12,15L17,10H7Z', moreVertical: 'M12 16a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0-6a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0-6a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2z', musicNote: 'M12 3v9.26c-.5-.17-1-.26-1.5-.26C8 12 6 14 6 16.5S8 21 10.5 21s4.5-2 4.5-4.5V6h4V3h-7z', openInNew: 'M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7Z', palette: 'M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5 1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9 9 9 0 0 0 9 9 1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8Z', person: 'M12 4a4 4 0 014 4 4 4 0 01-4 4 4 4 0 01-4-4 4 4 0 014-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4z', playlist: 'M15 6H3v2h12V6m0 4H3v2h12v-2M3 16h8v-2H3v2M17 6v8.18c-.31-.11-.65-.18-1-.18a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V8h3V6h-5z', playlistMusic: 'M15 6H3v2h12V6m0 4H3v2h12v-2M3 16h8v-2H3v2M17 6v8.18c-.31-.11-.65-.18-1-.18a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V8h3V6h-5Z', plus: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z', search: 'M9.5 3A6.5 6.5 0 0116 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27C12.59 15.41 11.11 16 9.5 16A6.5 6.5 0 013 9.5 6.5 6.5 0 019.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5z', shuffle: 'M14.83,13.41L13.42,14.82L16.55,17.95L14.5,20H20V14.5L17.96,16.54L14.83,13.41M14.5,4L16.54,6.04L4,18.59L5.41,20L17.96,7.46L20,9.5V4M10.59,9.17L5.41,4L4,5.41L9.17,10.58L10.59,9.17Z', sidePanel: 'M 5 21 C 4.449219 21 3.980469 20.804688 3.585938 20.414062 C 3.195312 20.019531 3 19.550781 3 19 L 3 5 C 3 4.449219 3.195312 3.980469 3.585938 3.585938 C 3.980469 3.195312 4.449219 3 5 3 L 19 3 C 19.550781 3 20.019531 3.195312 20.414062 3.585938 C 20.804688 3.980469 21 4.449219 21 5 L 21 19 C 21 19.550781 20.804688 20.019531 20.414062 20.414062 C 20.019531 20.804688 19.550781 21 19 21 Z M 12 19 L 19 19 L 19 5 L 12 5 Z M 12 19', sort: 'M3 13h12v-2H3m0-5v2h18V6M3 18h6v-2H3v2z', sortAscending: 'M19 17H22L18 21L14 17H17V3H19V17M7 3C4.79 3 3 4.79 3 7S4.79 11 7 11 11 9.21 11 7 9.21 3 7 3M7 9C5.9 9 5 8.1 5 7S5.9 5 7 5 9 5.9 9 7 8.1 9 7 9M7 13C4.79 13 3 14.79 3 17S4.79 21 7 21 11 19.21 11 17 9.21 13 7 13Z', trashOutline: 'M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12M8 9h8v10H8V9m7.5-5-1-1h-5l-1 1H5v2h14V4h-3.5Z', trayFull: 'M18 5H6V7H18M6 9H18V11H6M2 12H4V17H20V12H22V17A2 2 0 0 1 20 19H4A2 2 0 0 1 2 17M18 13H6V15H18Z', trayRemove: 'M2 17a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5h-2v5H4v-5H2m12.12-6.54 1.42 1.42L13.41 9l2.13 2.12-1.42 1.42L12 10.41l-2.12 2.13-1.42-1.42L10.59 9 8.46 6.88l1.42-1.42L12 7.59Z', volumeHigh: 'M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.5,8.71 14,7.97V16C15.5,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z', volumeMid: 'M5,9V15H9L14,20V4L9,9M18.5,12C18.5,10.23 17.5,8.71 16,7.97V16C17.5,15.29 18.5,13.76 18.5,12Z', } as const /** @public */ export type IconType = keyof typeof ICON_PATHS ================================================ FILE: src/lib/components/library-grid/LibraryGridItem.svelte ================================================ { e.preventDefault() menu.showFromEvent(e, menuItems(), { anchor: false, position: { top: e.y, left: e.x }, }) }} >
{#if query.loading}
{:else if query.error} {m.errorUnexpected()} {:else if item} {@render children(item)} {/if}
================================================ FILE: src/lib/components/library-grid/LibraryGridListContainer.svelte ================================================ `${items[index]}-${index}`} > {#snippet children(item)} {#snippet children(itemValue)} {@render itemSnippet(itemValue)} {/snippet} {/snippet} ================================================ FILE: src/lib/components/menu/Menu.svelte ================================================ { // There is no way to prevent dialog close event close() }} > ================================================ FILE: src/lib/components/menu/MenuRenderer.svelte ================================================ {#if data} {/if} ================================================ FILE: src/lib/components/menu/positioning.ts ================================================ import { assign } from '$lib/helpers/utils/assign.ts' import type { MenuAlignment, MenuPosition } from './types.ts' export const getMeasurementsFromAnchor = ( menuRect: DOMRect, anchor: Element, align?: MenuAlignment, ): { top: number left: number originY: number originX: number } => { const { horizontal: horizontalAlign = 'left', vertical: verticalAlign = 'top' } = align || {} const anchorRect = anchor.getBoundingClientRect() const { top: aTop, left: aLeft } = anchorRect const top = verticalAlign === 'top' ? aTop : anchorRect.bottom - menuRect.height const left = horizontalAlign === 'left' ? aLeft : anchorRect.right - menuRect.width const originY = Math.abs(aTop - top + anchorRect.height / 2) const originX = Math.abs(aLeft - left + anchorRect.width / 2) const position = { top, left, originY, originX, } return position } interface MenuPositioning extends MenuPosition { originY?: number originX?: number width: number height: number } export const positionMenu = (menuEl: HTMLElement, pos: MenuPositioning): void => { // Menu can't be placed outside of window bounds. const top = Math.min(pos.top, window.innerHeight - pos.height) const left = Math.min(pos.left, window.innerWidth - pos.width) assign(menuEl.style, { top: `${top}px`, left: `${left}px`, transformOrigin: `${pos.originX || 0}px ${pos.originY || 0}px`, }) } ================================================ FILE: src/lib/components/menu/types.ts ================================================ export interface MenuPosition { top: number left: number } export interface MenuAlignment { horizontal?: 'left' | 'right' vertical?: 'top' | 'bottom' } interface MenuAnchorOptions { anchor: true preferredAlignment?: MenuAlignment } interface MenuPositionOptions { anchor: false position: MenuPosition } interface MenuSize { width?: number height?: number } /** @public */ export type MenuOptions = (MenuAnchorOptions | MenuPositionOptions) & MenuSize /** @public */ export interface MenuItem { label: string selected?: boolean action: () => void } ================================================ FILE: src/lib/components/player/MainControls.svelte ================================================
================================================ FILE: src/lib/components/player/PlayerArtwork.svelte ================================================ ================================================ FILE: src/lib/components/player/Timeline.svelte ================================================
{currentTime()}
{ if (!seeking) { seekingValue = value } seeking = true }} onSeekEnd={() => { seeking = false playerSeek(seekingValue) }} />
{formatDuration(player.duration)}
================================================ FILE: src/lib/components/player/VolumeSlider.svelte ================================================ ================================================ FILE: src/lib/components/player/buttons/ActiveIndicator.svelte ================================================
================================================ FILE: src/lib/components/player/buttons/PlayNextButton.svelte ================================================ ================================================ FILE: src/lib/components/player/buttons/PlayPrevButton.svelte ================================================ ================================================ FILE: src/lib/components/player/buttons/PlayToggleButton.svelte ================================================ player.togglePlay()} > ================================================ FILE: src/lib/components/player/buttons/PlayTogglePillButton.svelte ================================================ ================================================ FILE: src/lib/components/player/buttons/PlayerFavoriteButton.svelte ================================================ {#if track} {/if} ================================================ FILE: src/lib/components/player/buttons/RepeatButton.svelte ================================================ ================================================ FILE: src/lib/components/player/buttons/ShuffleButton.svelte ================================================ ================================================ FILE: src/lib/components/playlists/PlaylistListContainer.svelte ================================================ `${items[index]}-${index}`}> {#snippet children(item)} {@const playlistId = items[item.index] as number} { onItemClick?.({ playlist, items, index: item.index, }) }} /> {/snippet} ================================================ FILE: src/lib/components/playlists/PlaylistListItem.svelte ================================================ { invariant(playlist) onclick?.(playlist) }} >
{#if typeof icon === 'function'} {#if playlist} {@render icon(playlist)} {/if} {:else}
{/if} {#if data.loading}
{:else if data.error} Error loading track {:else if playlist}
{playlist.name}
{/if}
================================================ FILE: src/lib/components/snackbar/Snackbar.svelte ================================================
{#if message}
{typeof message === 'function' ? message() : message}
{/if} {#if controls && 'type' in controls} {@render controls.snippet(controls.arg)} {:else if controls}
{#if controls} {/if}
{/if}
================================================ FILE: src/lib/components/snackbar/SnackbarRenderer.svelte ================================================ {#if snackbarItems.length > 0} {#each snackbarItems as item (item.id)}
{/each} {/if} ================================================ FILE: src/lib/components/snackbar/snackbar.ts ================================================ import type { SnackbarData } from './Snackbar.svelte' import { snackbarItems } from './store.svelte.ts' export type SnackbarOptions = SnackbarData const showSnackbar = (newSnackbar: SnackbarOptions | string): void => { let newSnackbarNormalized: SnackbarData if (typeof newSnackbar === 'string') { newSnackbarNormalized = { id: newSnackbar, message: newSnackbar } } else { newSnackbarNormalized = newSnackbar } const index = snackbarItems.findIndex((snackbar) => snackbar.id === newSnackbarNormalized.id) if (index > -1) { snackbarItems[index] = newSnackbarNormalized } else { snackbarItems.push(newSnackbarNormalized) } } /** @public */ export const snackbar = (newSnackbar: SnackbarOptions | string): void => { untrack(() => showSnackbar(newSnackbar)) } snackbar.dismiss = (id: string): void => { const index = snackbarItems.findIndex((snackbar) => snackbar.id === id) if (index > -1) { snackbarItems.splice(index, 1) } } snackbar.unexpectedError = (error: unknown) => { console.error('[SNACKBAR] Unexpected error', error) snackbar({ id: 'unexpected-error', message: m.errorUnexpected(), duration: 10_000, }) } ================================================ FILE: src/lib/components/snackbar/store.svelte.ts ================================================ import type { SnackbarData } from './Snackbar.svelte' // biome-ignore lint/suspicious/noExplicitAny: this can be anything export const snackbarItems: SnackbarData[] = $state([]) ================================================ FILE: src/lib/components/tracks/TrackListItem.svelte ================================================ { if (track) { onclick?.(track, e) } }} {onpointerenter} oncontextmenu={(e) => { if (!menuItemsWithItem) { return } e.preventDefault() // On mobile, enter selection mode instead of showing context menu if (e.pointerType === 'touch') { if (!selectionEnabled) { // Enter selection mode and select this item toggleSelection?.() } return } menu.showFromEvent(e, menuItemsWithItem(), { anchor: false, position: { top: e.y, left: e.x }, }) }} > {#if reorderInsertBefore || reorderInsertAfter}
{/if}
{#if activePlaying} {@const barClassName = 'playing-bar h-5 w-[3px] origin-bottom rounded-sm bg-[white]'}
{/if}
{#if loading}
{:else if query.error}
Error loading track with id {trackId}
{:else if track}
{track.name}
{formatArtists(track.artists)}
{#if showReorderButton && !selectionEnabled} {/if} {#if showFavoriteButton} {/if}
{#if !selectionEnabled}
{formatDuration(track.duration)}
{/if} { // If selection is not enabled, enable it // otherwise let parent handle toggling if (!selectionEnabled) { e.stopPropagation() toggleSelection?.() } }} >
{#if selected} {/if}
{/if}
================================================ FILE: src/lib/components/tracks/TracksListContainer.svelte ================================================ {#snippet multiselectPane()}
getMultiSelectMenuItems(selection.selectedIds)} alignment={{ horizontal: 'left', vertical: 'bottom' }} />
{m.selectedCount({ count: selection.size })}
{ selection.cancelSelection() }} />
{/snippet} `${items[index]}-${index}`} > {#snippet children(item)} {@const trackId = items[item.index] as number} {@const active = player.activeTrack?.id === trackId} {@const drag = dragController.drag} getMenuItems(track, item.index)} onclick={(track, e) => { selection.handleItemClick({ event: e, trackId, index: item.index, onClick: () => { onItemClick({ track, items, index: item.index, }) }, }) }} onpointerenter={() => { if (dragController.drag === null) { selection.handlePointerEnter(item.index) } }} toggleSelection={() => { selection.toggleSelection(trackId, item.index) }} onReorderPointerDown={(e) => { dragController.start(item.index, e) }} /> {/snippet} {#if dragController.drag !== null} {@const drag = dragController.drag} {@const previewTrackId = items[drag.fromIndex]} {#if previewTrackId !== undefined}
{ el.showPopover() }} > getMenuItems(track, drag.fromIndex)} {showReorderButton} {showFavoriteButton} reorderDragging={false} reorderInsertBefore={false} reorderInsertAfter={false} />
{/if} {/if} ================================================ FILE: src/lib/components/tracks/selection.svelte.ts ================================================ import { SvelteSet } from 'svelte/reactivity' export class SelectionTracker { #selectedIds: Set = new SvelteSet() #selectionEnabled = $state(false) get selectedIds() { return Array.from(this.#selectedIds) } get selectionEnabled() { return this.#selectionEnabled } rangeAnchor: number | null = null enterSelectionMode() { this.#selectionEnabled = true } toggle(id: number, index: number) { if (this.#selectedIds.has(id)) { this.#selectedIds.delete(id) } else { this.#selectedIds.add(id) } this.#selectionEnabled = this.#selectedIds.size > 0 if (this.#selectionEnabled) { this.rangeAnchor = index } else { this.rangeAnchor = null } } select(id: number, index: number) { this.#selectedIds.add(id) this.#selectionEnabled = true this.rangeAnchor = index } unselect(id: number) { this.#selectedIds.delete(id) this.#selectionEnabled = this.#selectedIds.size > 0 if (!this.#selectionEnabled) { this.rangeAnchor = null } } selectMany(ids: readonly number[]) { for (const id of ids) { this.#selectedIds.add(id) } this.#selectionEnabled = this.#selectedIds.size > 0 } unselectMany(ids: readonly number[]) { for (const id of ids) { this.#selectedIds.delete(id) } this.#selectionEnabled = this.#selectedIds.size > 0 if (!this.#selectionEnabled) { this.rangeAnchor = null } } /** Sets rangeAnchor only when there is no anchor yet (hover-preview entry point). */ setHoverAnchor(index: number) { if (this.rangeAnchor === null) { this.rangeAnchor = index } } /** Clears rangeAnchor when no items are selected (Shift release with no selection). */ clearHoverAnchor() { if (!this.#selectionEnabled) { this.rangeAnchor = null } } has(id: number) { return this.#selectedIds.has(id) } clear() { this.#selectedIds.clear() this.#selectionEnabled = false this.rangeAnchor = null } get size() { return this.#selectedIds.size } } ================================================ FILE: src/lib/components/tracks/use-track-drag-controller.svelte.ts ================================================ import { useScrollTarget } from '../ScrollContainer.svelte' const EDGE_THRESHOLD = 84 const MAX_SCROLL_STEP = 30 interface DragState { fromIndex: number insertIndex: number preview: { top: number left: number width: number } } interface UseTrackDragControllerOptions { itemsCount: () => number onReorder: ((from: number, to: number) => void) | undefined onStart?: () => void } export const useTrackDragController = ({ itemsCount, onReorder, onStart, }: UseTrackDragControllerOptions) => { const scrollTarget = useScrollTarget() let drag = $state(null) let activePointerId: number | null = null let pointerOffsetY = 0 let currentPointerY = 0 let dragItemCount = 0 let rafId: number | null = null let abortController: AbortController | null = null let scrollViewport = { top: 0, bottom: 0 } const refreshScrollViewport = () => { const target = scrollTarget.current if (target instanceof Window) { scrollViewport = { top: 0, bottom: target.innerHeight } return } const rect = target.getBoundingClientRect() scrollViewport = { top: rect.top, bottom: rect.bottom } } $effect(() => { const target = scrollTarget.current const observed = target instanceof Window ? document.documentElement : target refreshScrollViewport() const observer = new ResizeObserver(refreshScrollViewport) observer.observe(observed) return () => observer.disconnect() }) const scrollLoop = () => { const { top, bottom } = scrollViewport const topDelta = top + EDGE_THRESHOLD - currentPointerY const bottomDelta = currentPointerY - (bottom - EDGE_THRESHOLD) if (topDelta > 0) { scrollTarget.current.scrollBy( 0, -Math.round((topDelta / EDGE_THRESHOLD) * MAX_SCROLL_STEP), ) rafId = requestAnimationFrame(scrollLoop) } else if (bottomDelta > 0) { scrollTarget.current.scrollBy( 0, Math.round((bottomDelta / EDGE_THRESHOLD) * MAX_SCROLL_STEP), ) rafId = requestAnimationFrame(scrollLoop) } else { rafId = null } } const getInsertIndex = (x: number, y: number): number | null => { const target = document.elementFromPoint(x, y) if (!(target instanceof Element)) { return null } const row = target.closest('[aria-rowindex]') if (!(row instanceof HTMLElement)) { return null } const index = Number(row.ariaRowIndex) if (!Number.isInteger(index) || index < 0 || index >= dragItemCount) { return null } const rowRect = row.getBoundingClientRect() const isAfterHalf = y >= rowRect.top + rowRect.height / 2 return Math.max(0, Math.min(dragItemCount, isAfterHalf ? index + 1 : index)) } const stop = () => { drag = null activePointerId = null if (rafId !== null) { cancelAnimationFrame(rafId) rafId = null } abortController?.abort() abortController = null } const start = (index: number, e: PointerEvent) => { const count = itemsCount() if (!onReorder || index < 0 || index >= count) { return } e.preventDefault() e.stopPropagation() const rowElement = (e.currentTarget as HTMLElement | null)?.closest('[aria-rowindex]') if (!(rowElement instanceof HTMLElement)) { return } stop() onStart?.() const rowRect = rowElement.getBoundingClientRect() pointerOffsetY = e.clientY - rowRect.top activePointerId = e.pointerId dragItemCount = count drag = { fromIndex: index, insertIndex: index, preview: { top: rowRect.top, left: rowRect.left, width: rowRect.width }, } abortController = new AbortController() const onMove = (event: PointerEvent) => { if (event.pointerId !== activePointerId || !drag) { return } event.preventDefault() drag.preview.top = event.clientY - pointerOffsetY currentPointerY = event.clientY if (rafId === null) { rafId = requestAnimationFrame(scrollLoop) } const newInsertIndex = getInsertIndex(event.clientX, event.clientY) if (newInsertIndex !== null) { drag.insertIndex = newInsertIndex } } const onEnd = (event: PointerEvent) => { if (event.pointerId !== activePointerId || !drag) { return } const from = drag.fromIndex const insertIndex = drag.insertIndex stop() // insertIndex is a slot *between* items; when the item moved downward the // slot index is one ahead of the target item index, so subtract 1. const to = insertIndex > from ? insertIndex - 1 : insertIndex if (to !== from) { onReorder(from, to) } } window.addEventListener('pointermove', onMove, { passive: false, signal: abortController.signal, }) window.addEventListener('pointerup', onEnd, { signal: abortController.signal }) window.addEventListener('pointercancel', onEnd, { signal: abortController.signal }) } return { get drag() { return drag }, start, stop, } } ================================================ FILE: src/lib/components/tracks/use-track-menu-items.ts ================================================ import { goto } from '$app/navigation' import { resolve } from '$app/paths' import { getDatabase } from '$lib/db/database.ts' import type { TrackData } from '$lib/library/get/value' import { toggleFavoriteTrack } from '$lib/library/playlists-actions' import type { MenuItem } from '../menu/types.ts' export type PredefinedTrackMenuItemOption = | 'disableAddToQueue' | 'disableAddToPlaylist' | 'disableRemoveFromLibrary' | 'disableAddToFavorites' | 'disableViewAlbum' | 'disableViewArtist' | 'enableMultiRemoveFromFavorites' interface PredefinedMenuItem extends MenuItem { predefinedKey: PredefinedTrackMenuItemOption } type FalsyValue = false | undefined | null | '' type UnfilteredPredefinedMenuItem = PredefinedMenuItem | FalsyValue const viewRelated = async (store: 'albums' | 'artists', name: string) => { try { const db = await getDatabase() const album = await db.getFromIndex(store, 'name', name) invariant(album) const path = resolve('/(app)/library/[[slug=libraryEntities]]/[uuid]', { slug: store, uuid: album.uuid, }) await goto(path) } catch (error) { snackbar.unexpectedError(error) } } export const useTrackMenuItems = ( getMenuItemsFn: () => ((track: TrackData, index: number) => MenuItem[]) | null | undefined, predefinedItemsOptions: () => Partial>, ) => { const dialogs = useDialogsStore() const player = usePlayer() const filterPredefinedItems = (items: UnfilteredPredefinedMenuItem[]) => { const options = predefinedItemsOptions() const predefinedItems = items.filter((item) => { if (!item) { return false } const valueFromOptions = options[item.predefinedKey] if (item.predefinedKey.startsWith('disable')) { return valueFromOptions === undefined ? true : !valueFromOptions } return valueFromOptions ?? false }) as MenuItem[] return predefinedItems } const getMenuItems = (track: TrackData, index: number) => { const albumName = track.album // In a future we should handle ability to view multiple artists const artistName = track.artists[0] const predefinedItems: UnfilteredPredefinedMenuItem[] = [ { predefinedKey: 'disableAddToPlaylist', label: m.libraryAddToPlaylist(), action: () => { dialogs.openDialog('addToPlaylist', [track.id]) }, }, { predefinedKey: 'disableAddToFavorites', label: track.favorite ? m.trackRemoveFromFavorites() : m.trackAddToFavorites(), action: () => { void toggleFavoriteTrack(track.favorite, track.id) }, }, { predefinedKey: 'disableAddToQueue', label: m.playerAddToQueue(), action: () => { player.addToQueue(track.id) }, }, albumName && { predefinedKey: 'disableViewAlbum', label: m.trackViewAlbum(), action: () => { void viewRelated('albums', albumName) }, }, artistName && { predefinedKey: 'disableViewArtist', label: m.trackViewArtist(), action: () => { void viewRelated('artists', artistName) }, }, { predefinedKey: 'disableRemoveFromLibrary', label: m.libraryRemoveFromLibrary(), action: () => { dialogs.openDialog('removeFromLibrary', { type: 'single', name: track.name, id: track.id, storeName: 'tracks', }) }, }, ] const menuItems = getMenuItemsFn() return [ ...filterPredefinedItems(predefinedItems), ...(menuItems ? menuItems(track, index) : []), ] } const getMultiSelectMenuItems = (trackIds: readonly number[]) => { const predefinedItems: UnfilteredPredefinedMenuItem[] = [ { predefinedKey: 'disableAddToPlaylist', label: m.libraryAddToPlaylist(), action: () => { dialogs.openDialog('addToPlaylist', trackIds) }, }, { predefinedKey: 'disableAddToFavorites', label: m.trackAddToFavorites(), action: () => { trackIds.forEach((trackId) => { void toggleFavoriteTrack(false, trackId) }) }, }, { predefinedKey: 'disableAddToQueue', label: m.playerAddToQueue(), action: () => { player.addToQueue(trackIds) }, }, { predefinedKey: 'enableMultiRemoveFromFavorites', label: m.trackRemoveFromFavorites(), action: () => { trackIds.forEach((trackId) => { void toggleFavoriteTrack(true, trackId) }) }, }, { predefinedKey: 'disableRemoveFromLibrary', label: m.libraryRemoveFromLibrary(), action: () => { dialogs.openDialog('removeFromLibrary', { type: 'multiple', ids: trackIds, storeName: 'tracks', }) }, }, ] return filterPredefinedItems(predefinedItems) } return { getMenuItems, getMultiSelectMenuItems, } } ================================================ FILE: src/lib/components/tracks/use-track-selection-controller.svelte.ts ================================================ import { isPrimaryModifierKey } from '$lib/helpers/utils/ua.ts' import { SelectionTracker } from './selection.svelte.ts' interface SelectionInteractionState { hoverRangeEnd: number | null isShiftActive: boolean } interface UseTrackSelectionControllerOptions { items: () => readonly number[] } interface HandleItemClickOptions { event: MouseEvent | KeyboardEvent trackId: number index: number onClick: () => void } export const useTrackSelectionController = ({ items }: UseTrackSelectionControllerOptions) => { const selection = new SelectionTracker() const state: SelectionInteractionState = $state({ hoverRangeEnd: null, isShiftActive: false, }) const cancelSelection = () => { selection.clear() state.hoverRangeEnd = null } $effect(() => { const ac = new AbortController() const { signal } = ac document.addEventListener( 'keydown', (e: KeyboardEvent) => { if (e.key === 'Shift') { state.isShiftActive = true } if (!selection.selectionEnabled) { return } if (e.key === 'Escape') { cancelSelection() return } if (e.key === 'a' && isPrimaryModifierKey(e)) { e.preventDefault() selection.selectMany(items()) } }, { signal }, ) document.addEventListener( 'keyup', (e: KeyboardEvent) => { if (e.key === 'Shift') { state.isShiftActive = false selection.clearHoverAnchor() } }, { signal }, ) return () => ac.abort() }) const isInHoverRange = (index: number) => { if (!state.isShiftActive || state.hoverRangeEnd === null) { return false } const anchor = selection.rangeAnchor if (anchor === null) { return false } const min = Math.min(anchor, state.hoverRangeEnd) const max = Math.max(anchor, state.hoverRangeEnd) return index >= min && index <= max } const handlePointerEnter = (index: number) => { if (state.isShiftActive || selection.selectionEnabled) { state.hoverRangeEnd = index if (state.isShiftActive) { selection.setHoverAnchor(index) } } } const applyShiftClick = (trackId: number, index: number) => { if (!selection.selectionEnabled) { selection.enterSelectionMode() } if (selection.rangeAnchor === null) { selection.select(trackId, index) return } const allItems = items() const min = Math.min(selection.rangeAnchor, index) const max = Math.max(selection.rangeAnchor, index) const rangeIds: number[] = [] let allSelected = true for (let i = min; i <= max; i += 1) { const itemAtIndex = allItems[i] if (itemAtIndex === undefined) { continue } rangeIds.push(itemAtIndex) if (allSelected && !selection.has(itemAtIndex)) { allSelected = false } } if (allSelected) { selection.unselectMany(rangeIds) } else { selection.selectMany(rangeIds) } selection.rangeAnchor = index } const handleItemClick = ({ event, trackId, index, onClick }: HandleItemClickOptions) => { if (isPrimaryModifierKey(event)) { event.preventDefault() selection.toggle(trackId, index) return } if (event.shiftKey) { event.preventDefault() applyShiftClick(trackId, index) return } if (selection.selectionEnabled) { selection.toggle(trackId, index) return } onClick() } return { get selectionEnabled() { return selection.selectionEnabled }, get selectedIds() { return selection.selectedIds }, get size() { return selection.size }, has: (trackId: number) => selection.has(trackId), selectMany: (trackIds: readonly number[]) => selection.selectMany(trackIds), toggleSelection: (trackId: number, index: number) => selection.toggle(trackId, index), cancelSelection, isInHoverRange, handlePointerEnter, handleItemClick, } } ================================================ FILE: src/lib/db/database.ts ================================================ import type { DBSchema, IDBPDatabase, IDBPObjectStore, IndexNames, StoreNames } from 'idb' import { openDB } from 'idb' import type { Album, Artist, Directory, PlayHistoryEntry, Playlist, PlaylistEntry, Track, } from '$lib/library/types.ts' import type { DbBaseChange, DbStandardChange } from './events.ts' export interface AppDB extends DBSchema { tracks: { key: number value: Track indexes: Pick< Track, | 'uuid' | 'name' | 'album' | 'year' | 'duration' | 'artists' | 'directory' | 'fileName' | 'scannedAt' > & { path: [directoryId: number, fileName: string] byAlbumSorted: [album: string, name: string, trackNo: number, discNo: number] } meta: { operations: DbStandardChange<'tracks'> } } albums: { key: number value: Album indexes: Pick meta: { operations: DbStandardChange<'albums'> } } artists: { key: number value: Artist indexes: Pick meta: { operations: DbStandardChange<'artists'> } } playlists: { key: number value: Playlist indexes: Pick meta: { operations: DbStandardChange<'playlists'> } } playlistEntries: { key: number value: PlaylistEntry indexes: Pick & { playlistTrack: [playlistId: number, trackId: number] } meta: { operations: | DbBaseChange<'playlistEntries', 'add', true> | DbBaseChange<'playlistEntries', 'delete', true> } } directories: { key: number value: Directory indexes: Pick meta: { operations: DbStandardChange<'directories'> } } playHistory: { key: number value: PlayHistoryEntry indexes: Pick meta: { operations: { storeName: 'playHistory' } } } } export type AppStoreNames = StoreNames export type AppIndexNames = IndexNames const createIndexes = ( store: IDBPObjectStore, Name, 'versionchange'>, indexes: readonly AppIndexNames[], options: IDBIndexParameters = {}, ) => { for (const name of indexes) { store.createIndex(name, name, options) } } const createStore = >( db: IDBPDatabase, storeName: Name, ) => db.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true, }) const openAppDatabase = () => openDB('snae-app-data', 3, { async upgrade(db, oldVersion, _newVersion, tx) { const { objectStoreNames } = db if (!objectStoreNames.contains('tracks')) { const store = createStore(db, 'tracks') createIndexes(store, ['uuid'], { unique: true }) createIndexes( store, ['name', 'album', 'year', 'duration', 'scannedAt', 'directory'], { unique: false, }, ) store.createIndex('path', ['directory', 'fileName'], { // We keep flat folder structure in the database // but in actual FS multiple files with same name // can exist in different directories unique: false, }) store.createIndex('artists', 'artists', { unique: false, multiEntry: true, }) } const tracksStore = tx.objectStore('tracks') if (!tracksStore.indexNames.contains('byAlbumSorted')) { tx.objectStore('tracks').createIndex( 'byAlbumSorted', ['album', 'discNo', 'trackNo', 'name'], { unique: false, }, ) } if (oldVersion === 1) { // Previous versions didn't have discNo and trackNo fields for await (const cursor of tracksStore) { const track = cursor.value if (track.discNo === undefined || track.trackNo === undefined) { await cursor.update({ ...track, discNo: track.discNo ?? 0, discOf: track.discOf ?? 0, trackNo: track.trackNo ?? 0, trackOf: track.trackOf ?? 0, }) } } } if (!objectStoreNames.contains('albums')) { const store = createStore(db, 'albums') createIndexes(store, ['name', 'uuid'], { unique: true }) createIndexes(store, ['year']) store.createIndex('artists', 'artists', { unique: false, multiEntry: true, }) } if (!objectStoreNames.contains('artists')) { const store = createStore(db, 'artists') createIndexes(store, ['name', 'uuid'], { unique: true }) } if (!objectStoreNames.contains('playlists')) { const store = createStore(db, 'playlists') createIndexes(store, ['uuid'], { unique: true }) createIndexes(store, ['name', 'createdAt']) } if (!objectStoreNames.contains('playlistEntries')) { const store = db.createObjectStore('playlistEntries', { keyPath: 'id', autoIncrement: true, }) createIndexes(store, ['playlistId', 'trackId', 'addedAt']) store.createIndex('playlistTrack', ['playlistId', 'trackId']) } if (!objectStoreNames.contains('directories')) { createStore(db, 'directories') } if (!objectStoreNames.contains('playHistory')) { const store = createStore(db, 'playHistory') createIndexes(store, ['trackId'], { unique: true }) createIndexes(store, ['playedAt']) } }, }) type AppIDBDatabase = IDBPDatabase let dbPromise: Promise | AppIDBDatabase | null = null export const getDatabase = (): Promise | AppIDBDatabase => { if (dbPromise !== null) { return dbPromise } dbPromise = openAppDatabase() dbPromise .then((db) => { db.onclose = () => { dbPromise = null } // Micro optimization to avoid unwrapping the promise dbPromise = db }) .catch(() => { dbPromise = null }) return dbPromise } export type DbKey = AppDB[Name]['key'] export type DbValue = AppDB[Name]['value'] ================================================ FILE: src/lib/db/events.ts ================================================ import type { AppDB, AppStoreNames } from './database.ts' export type DbBaseChange< StoreName extends AppStoreNames, Operation extends 'add' | 'update' | 'delete' | (string & {}), IncludeValue extends boolean = false, > = { storeName: StoreName operation: Operation key: AppDB[StoreName]['key'] } & (IncludeValue extends true ? { value: AppDB[StoreName]['value'] } : unknown) export type DbStandardChange< StoreName extends AppStoreNames, IncludeValue extends boolean = false, > = | DbBaseChange | DbBaseChange | DbBaseChange export type DatabaseChangeDetails = { [StoreName in AppStoreNames]: AppDB[StoreName]['meta']['operations'] }[AppStoreNames] export type DatabaseChangeDetailsList = readonly DatabaseChangeDetails[] // We need to notify our local frame and all other frames about database changes. // Including web workers, other tabs, etc. const crossChannel = new BroadcastChannel('db-changes') const localChannel = new EventTarget() type Listener = (changes: readonly DatabaseChangeDetails[]) => void // It is faster to manually store listeners in a Set, than registering 2 EventTargets. const listeners = new Set() // We only want listeners to be registered in the main thread. if (globalThis.window) { const notifyListeners = (changes: readonly DatabaseChangeDetails[]) => { for (const listener of listeners) { listener(changes) } } localChannel.addEventListener('message', (e: CustomEventInit) => { const changes = e.detail if (!changes) { return } notifyListeners(changes) }) crossChannel.addEventListener('message', (e: MessageEvent) => { notifyListeners(e.data) }) } export const onDatabaseChange = ( handler: (changes: readonly DatabaseChangeDetails[]) => void, ): (() => void) => { if (import.meta.env.SSR) { return () => {} } listeners.add(handler) return () => { listeners.delete(handler) } } export const dispatchDatabaseChangedEvent = ( changes: readonly (DatabaseChangeDetails | undefined)[] | DatabaseChangeDetails, ): void => { const changesArray = Array.isArray(changes) ? changes : [changes] const filteredChanges = changesArray.filter((c) => c !== undefined) if (filteredChanges.length === 0) { return } localChannel.dispatchEvent(new CustomEvent('message', { detail: filteredChanges })) crossChannel.postMessage(filteredChanges) } ================================================ FILE: src/lib/db/lock-database.ts ================================================ import { SvelteSet } from 'svelte/reactivity' let counter = 0 const pendingTasks = new SvelteSet() /** * Returns reactive boolean value stating if database operation is pending. */ export const isDatabaseOperationPending = (): boolean => pendingTasks.size > 0 /** * Prevents other tasks using this function from running while one is pending. * Works between different tabs/threads using the * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API Web Locks API } * * Generally, should only be used for long-running database mutation operations. */ export const lockDatabase = async (action: () => Promise): Promise => { const id = counter counter += 1 pendingTasks.add(id) try { return await navigator.locks.request('database', () => action()) } finally { pendingTasks.delete(id) } } ================================================ FILE: src/lib/db/query/base-query.svelte.ts ================================================ import { assign } from '$lib/helpers/utils/assign.ts' import { type DatabaseChangeDetailsList, onDatabaseChange } from '../events.ts' export type QueryStatus = 'loading' | 'loaded' | 'error' interface QueryBaseResult { status: QueryStatus } interface QueryLoadedResult { status: 'loaded' loading: false value: Result error: undefined } interface QueryLoadingResult { status: 'loading' loading: true value: Result | undefined error: undefined } interface QueryErrorResult { status: 'error' loading: false value: Result | undefined error: unknown } export type QueryResult = QueryBaseResult & (QueryLoadedResult | QueryLoadingResult | QueryErrorResult) export type QueryMutate = (value: Result | ((prev: Result | undefined) => Result)) => void export interface DbChangeActions { mutate: QueryMutate refetch: () => void } export type DatabaseChangeHandler = ( changes: DatabaseChangeDetailsList, actions: DbChangeActions, ) => void export type QueryKeyPrimitiveValue = number | string | boolean export type QueryKey = QueryKeyPrimitiveValue | QueryKeyPrimitiveValue[] const normalizeKey = (key: K): QueryKeyPrimitiveValue => Array.isArray(key) ? key.join(',') : key export interface QueryBaseOptions { key: K | (() => K) fetcher: (key: K, signal: AbortSignal) => Promise | Result onDatabaseChange?: DatabaseChangeHandler } export type QueryStateInternal = Omit, 'loading'> export class QueryImpl { state: QueryStateInternal = $state({ status: 'loading', error: undefined, value: undefined, }) resolvedKey: QueryKeyPrimitiveValue | undefined = undefined #abortController: AbortController | undefined = undefined options: QueryBaseOptions constructor(options: QueryBaseOptions) { this.options = options } #getKey() { const key = this.options.key return typeof key === 'function' ? key() : key } #setErrorState = (e: unknown, normalizedKey: QueryKeyPrimitiveValue) => { this.resolvedKey = normalizedKey assign(this.state, { status: 'error', value: undefined, error: e, }) } #setLoadedState = (value: Result, normalizedKey: QueryKeyPrimitiveValue) => { this.resolvedKey = normalizedKey assign(this.state, { status: 'loaded', value, error: undefined, }) } #loadWithKey = async (key: K, normalizedKey: QueryKeyPrimitiveValue) => { this.#abortController?.abort() const controller = new AbortController() this.#abortController = controller try { const result = this.options.fetcher(key, controller.signal) if (result instanceof Promise) { // We only need to set loading state if it is async assign(this.state, { status: 'loading', error: undefined, }) const resultValue = await result this.#setLoadedState(resultValue, normalizedKey) } else { this.#setLoadedState(result, normalizedKey) } } catch (e) { this.#setErrorState(e, normalizedKey) } } load = (): Promise => { const key = this.#getKey() const normalizedKey = normalizeKey(key) return this.#loadWithKey(key, normalizedKey) } setupListeners = (): void => { $effect(() => { const key = this.#getKey() const normalizedKey = normalizeKey(key) untrack(() => { if (this.resolvedKey === normalizedKey) { return } void this.#loadWithKey(key, normalizedKey) }) }) $effect(() => { const stopListening = untrack(() => onDatabaseChange((changes) => { this.options.onDatabaseChange?.(changes, { mutate: (v) => { let value: Result | undefined if (typeof v === 'function') { const accessor = v as (prev: Result | undefined) => Result value = accessor(this.state.value) } this.#setLoadedState(value as Result, normalizeKey(this.#getKey())) }, refetch: async () => { await this.load() }, }) }), ) return () => { stopListening() } }) } } export class QueryResultBox { #state: QueryStateInternal constructor(state: QueryStateInternal) { this.#state = state } get value() { return this.#state.value } get error() { return this.#state.error } get status() { return this.#state.status } get loading() { return this.#state.status === 'loading' } } ================================================ FILE: src/lib/db/query/inline-query.svelte.ts ================================================ import { type DatabaseChangeDetailsList, onDatabaseChange } from '../events.ts' import type { QueryKey } from './base-query.svelte.ts' export type { QueryKey, QueryResult } from './base-query.svelte.ts' export interface InlineQueryOptions { key: K | (() => K) fetcher: (key: K) => Promise | Result onDatabaseChange?: (changes: DatabaseChangeDetailsList) => boolean } export const createInlineQuery = ( options: InlineQueryOptions, ) => { let counter = $state(0) const load = () => { void counter const key = typeof options.key === 'function' ? options.key() : options.key return untrack(() => options.fetcher(key)) } $effect(() => { const stopListening = untrack(() => onDatabaseChange((changes) => { const changed = options.onDatabaseChange?.(changes) if (changed) { counter += 1 } }), ) return () => { stopListening() } }) return load } ================================================ FILE: src/lib/db/query/page-query.svelte.ts ================================================ import { type QueryBaseOptions, QueryImpl, type QueryKey, type QueryResult, QueryResultBox, type QueryStateInternal, } from './base-query.svelte.ts' export type { QueryKey } from './base-query.svelte.ts' export type PageQueryResult = QueryResult & { value: Result } export type PageQueryOptions = QueryBaseOptions const pageQueryHydrateSymbol: unique symbol = Symbol() class PageQueryResultBox extends QueryResultBox { #setupListeners: () => void #hydrated = false constructor(state: QueryStateInternal, setupListeners: () => void) { super(state) this.#setupListeners = setupListeners } [pageQueryHydrateSymbol](): void { if (this.#hydrated) { return } this.#hydrated = true this.#setupListeners() } } /** * Create a page query which should load data inside load function * and then listen for database changes once page is loaded. * @public */ export const createPageQuery = async ( options: PageQueryOptions, ): Promise> => { const query = new QueryImpl(options) const { state } = query await query.load() if (state.error) { throw state.error } const result = new PageQueryResultBox( state, query.setupListeners, ) as PageQueryResult return result } /** * Initialize pages queries once page is loaded. * Should be called inside page component. * @public */ export const initPageQueries = (data: () => Record): void => { $effect.pre(() => { for (const query of Object.values(data())) { if (query instanceof PageQueryResultBox) { query[pageQueryHydrateSymbol]() } } }) } ================================================ FILE: src/lib/db/query/query.ts ================================================ import { QueryImpl, type QueryKey, type QueryBaseOptions as QueryOptions, type QueryResult, QueryResultBox, } from './base-query.svelte.ts' export type { QueryKey, QueryResult } from './base-query.svelte.ts' export type { QueryOptions } export const createQuery = (options: QueryOptions) => { const query = new QueryImpl(options) query.setupListeners() void query.load() return new QueryResultBox(query.state) as QueryResult } ================================================ FILE: src/lib/helpers/__tests__/serial-queue.test.ts ================================================ /** biome-ignore-all lint/suspicious/useAwait: test code */ import { describe, expect, it, vi } from 'vitest' import { SerialQueue } from '../serial-queue.ts' import { wait } from '../utils/wait.ts' describe('SerialQueue', () => { it('executes a single task', async () => { const queue = new SerialQueue() const fn = vi.fn().mockResolvedValue(undefined) await queue.enqueue(fn) expect(fn).toHaveBeenCalledOnce() }) it('executes tasks in order', async () => { const queue = new SerialQueue() const order: number[] = [] void queue.enqueue(async () => { order.push(1) }) void queue.enqueue(async () => { order.push(2) }) void queue.enqueue(async () => { order.push(3) }) await queue.drain() expect(order).toEqual([1, 2, 3]) }) it('waits for the previous task to finish before starting the next', async () => { const queue = new SerialQueue() let running = 0 let maxConcurrent = 0 const makeTask = () => async () => { running += 1 maxConcurrent = Math.max(maxConcurrent, running) await wait(10) running -= 1 } void queue.enqueue(makeTask()) void queue.enqueue(makeTask()) void queue.enqueue(makeTask()) await queue.drain() expect(maxConcurrent).toBe(1) }) it('drain() resolves after all enqueued tasks complete', async () => { const queue = new SerialQueue() const completed: number[] = [] void queue.enqueue(async () => { await new Promise((r) => setTimeout(r, 20)) completed.push(1) }) void queue.enqueue(async () => { completed.push(2) }) await queue.drain() expect(completed).toEqual([1, 2]) }) it('drain() resolves immediately when queue is empty', async () => { const queue = new SerialQueue() await expect(queue.drain()).resolves.toBeUndefined() }) it('continues processing subsequent tasks after a task throws', async () => { const queue = new SerialQueue() const order: string[] = [] const failing = queue.enqueue(async () => { order.push('failing') throw new Error('oops') }) void queue.enqueue(async () => { order.push('after-failure') }) await expect(failing).rejects.toThrow('oops') await queue.drain() expect(order).toEqual(['failing', 'after-failure']) }) it('enqueue() returns the promise from the task function', async () => { const queue = new SerialQueue() const result = queue.enqueue(async () => { await Promise.resolve() }) await expect(result).resolves.toBeUndefined() }) it('enqueue() propagates task rejection to the caller', async () => { const queue = new SerialQueue() const result = queue.enqueue(async () => { throw new Error('task error') }) await expect(result).rejects.toThrow('task error') }) }) ================================================ FILE: src/lib/helpers/animations.ts ================================================ /** @public */ export const animateEmpty = ( element: Element, options: number | KeyframeAnimationOptions, ): Animation => element.animate(null, options) export interface SequenceKeyframeAnimationOptions extends KeyframeAnimationOptions { /** '<' means start at the same time as previous animation */ at?: '<' } export type AnimationSequence = [ Element, Keyframe[] | PropertyIndexedKeyframes, SequenceKeyframeAnimationOptions?, ] export interface AnimationSequenceOptions { defaultOptions?: KeyframeAnimationOptions } export const timeline = async ( sequence: readonly AnimationSequence[], sequenceOptions: AnimationSequenceOptions = {}, ): Promise => { const animations: readonly [Animation, runWithPrevious: boolean][] = sequence.map( ([element, keyframes, options]) => { const animation = element.animate(keyframes, { ...sequenceOptions.defaultOptions, ...options, }) animation.pause() return [animation, options?.at === '<'] }, ) const promises: Promise[] = [] for (const [animation, runWithPrevious] of animations) { if (!runWithPrevious) { await promises.at(-1) } animation.play() promises.push(animation.finished) } return Promise.all(promises) } ================================================ FILE: src/lib/helpers/audio.ts ================================================ import { isMobile, isSafari } from './utils/ua.ts' /** * Safari mobile does not allow changing audio volume * @public */ export const supportsChangingAudioVolume = () => { if (isMobile() && isSafari()) { return false } return true } ================================================ FILE: src/lib/helpers/create-managed-artwork.svelte.ts ================================================ class Artwork { static idCounter = 0 static createRefId() { const index = Artwork.idCounter Artwork.idCounter += 1 return index } image: Blob url: string refs = new Set() constructor(image: Blob) { this.image = image this.url = URL.createObjectURL(image) } } const cache = new WeakMap() const cleanupQueue = new Set() let isCleanupScheduled = false const scheduleCleanup = (artwork: Artwork) => { cleanupQueue.add(artwork.image) if (isCleanupScheduled) { return } isCleanupScheduled = true const thirtySeconds = 30 * 1000 setTimeout(() => { for (const blob of cleanupQueue) { const cached = cache.get(blob) if (!cached) { continue } if (cached.refs.size === 0) { cache.delete(blob) URL.revokeObjectURL(cached.url) } } cleanupQueue.clear() isCleanupScheduled = false }, thirtySeconds) } export const createManagedArtwork = (getImage: () => Blob | undefined | null) => { const refId = Artwork.createRefId() const artwork = $derived.by(() => { const image = getImage() if (!image) { return null } let artworkInstance = cache.get(image) if (!artworkInstance) { artworkInstance = new Artwork(image) cache.set(image, artworkInstance) } artworkInstance.refs.add(refId) return artworkInstance }) $effect(() => { // Need to use variable here so cleanup uses // previous value instead of the current one const savedArtwork = artwork if (!savedArtwork) { return } return () => { if (savedArtwork.refs.size === 1) { scheduleCleanup(savedArtwork) } if (import.meta.env.DEV && !savedArtwork.refs.has(refId)) { console.warn('Trying to release artwork that is not in use', savedArtwork) } savedArtwork.refs.delete(refId) } }) return () => artwork?.url } ================================================ FILE: src/lib/helpers/debounced.svelte.ts ================================================ import { debounce } from './utils/debounce.ts' type Getter = () => T export class Debounced { #current: T = $state() as T get current(): T { return this.#current } constructor(getter: Getter, delay: number) { this.#current = getter() const debouncedFn = debounce((v: T) => { this.#current = v }, delay) $effect(() => { const value = getter() debouncedFn(value) return () => { debouncedFn.cancel() } }) } } ================================================ FILE: src/lib/helpers/file-system.ts ================================================ import { isMobile } from '$lib/helpers/utils/ua.ts' export const isFileSystemAccessSupported: boolean = 'showDirectoryPicker' in globalThis export type FileEntity = File | FileSystemFileHandle const supportedExtensions = ['aac', 'mp3', 'ogg', 'wav', 'flac', 'm4a', 'opus', 'webm'] const supportedExtensionsWithDot = supportedExtensions.map((ext) => `.${ext}`) const isSupportedFile = (fileName: string): boolean => { // On Windows .MP3 and .mp3 are both valid file extensions const fileNameLower = fileName.toLowerCase() return supportedExtensionsWithDot.some((ext) => fileNameLower.endsWith(ext)) } export const getFileHandlesRecursively = async ( directory: FileSystemDirectoryHandle, ): Promise => { const files: FileSystemFileHandle[] = [] for await (const handle of directory.values()) { if (handle.kind === 'file') { const isValidFile = isSupportedFile(handle.name) if (isValidFile) { files.push(handle) } } else if (handle.kind === 'directory') { const additionalFiles = await getFileHandlesRecursively(handle) files.push(...additionalFiles) } } return files } const getFilesFromLegacyInputEvent = (e: Event): File[] => { const { files } = e.target as HTMLInputElement if (!files) { return [] } return Array.from(files).filter((file) => isSupportedFile(file.name)) } export const getFilesFromLegacyDirectory = (): Promise => { const directoryElement = document.createElement('input') directoryElement.type = 'file' // Mobile devices do not support directory selection, // so allow them to pick individual files instead. if (isMobile()) { directoryElement.accept = supportedExtensionsWithDot.join(', ') directoryElement.multiple = true } else { directoryElement.setAttribute('webkitdirectory', '') directoryElement.setAttribute('directory', '') } const { promise, resolve: resolvePromise } = Promise.withResolvers() const resolve = (files: File[]) => { directoryElement.remove() resolvePromise(files) } directoryElement.addEventListener('change', (e) => { resolve(getFilesFromLegacyInputEvent(e)) }) directoryElement.addEventListener('cancel', () => { resolve([]) }) directoryElement.addEventListener('error', () => { resolve([]) }) // See https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only directoryElement.style.position = 'fixed' directoryElement.style.top = '-100000px' directoryElement.style.left = '-100000px' document.body.appendChild(directoryElement) directoryElement.click() return promise } ================================================ FILE: src/lib/helpers/focus.ts ================================================ export const doesElementHasFocus = (element: Element): boolean => element.matches(':focus') export const findFocusedElement = (container: Element | Document): HTMLElement | null => { const element = container.querySelector(':focus') // If element contains focus it must be instanceof HTMLElement, // otherwise it's always null return element as HTMLElement | null } ================================================ FILE: src/lib/helpers/input.ts ================================================ const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'password', 'number']) /** * Checks if the given element is a text input or textarea. */ export const isElementTextInput = (element: Element | EventTarget | undefined | null) => { if ( (element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type)) || element instanceof HTMLTextAreaElement ) { return true } return false } ================================================ FILE: src/lib/helpers/persist.svelte.ts ================================================ const getValue = (key: string) => { try { const valueRaw = localStorage.getItem(key) const value = valueRaw === null || valueRaw === undefined ? null : JSON.parse(valueRaw) return value } catch (error) { console.error(`Failed to get persisted value for key "${key}"`, error) return null } } const getStorageKey = (storeName: string, key: string) => `snaeplayer-${storeName}.${key}` export const getPersistedValue = ( storeName: string, key: string, defaultValue: D = null as D, ): T | D => { const fullKey = getStorageKey(storeName, key) const value = getValue(fullKey) return value ?? defaultValue } export const persist = (storeName: string, instance: T, keys: (keyof T & string)[]): void => { $effect.root(() => { for (const key of keys) { const storageKey = getStorageKey(storeName, key) const value = getValue(storageKey) if (value !== null) { instance[key] = value } let initial = true $effect(() => { const updatedValue = instance[key] if (initial) { initial = false return } localStorage.setItem(storageKey, JSON.stringify(updatedValue)) }) } }) } ================================================ FILE: src/lib/helpers/register-sw.ts ================================================ // https://whatwebcando.today/articles/handling-service-worker-updates/ const waitForPageToLoad = () => { const { promise, resolve } = Promise.withResolvers() if (document.readyState === 'loading') { window.addEventListener('load', () => resolve(), { once: true }) } else { resolve() } return promise } /** @public */ export interface RegisterSwOptions { onNeedRefresh: (updateSw: () => void) => void } /** @public */ export const registerServiceWorker = async (options: RegisterSwOptions) => { if (import.meta.env.DEV) { return } await waitForPageToLoad() const { serviceWorker } = navigator const registration = await serviceWorker.register('/service-worker.js', { scope: '/', }) const needsRefresh = (reg: ServiceWorkerRegistration) => { const updateSw = () => { const { waiting } = reg if (waiting) { waiting.postMessage('skip-waiting') } } options.onNeedRefresh(updateSw) } // ensure the case when the updatefound event was missed is also handled // by re-invoking the prompt when there's a waiting Service Worker if (registration.waiting) { needsRefresh(registration) } let firstLoad = false registration.addEventListener('updatefound', () => { const { installing } = registration if (!installing) { return } // wait until the new Service worker is actually installed (ready to take over) installing.addEventListener('statechange', () => { if (registration.waiting) { if (navigator.serviceWorker.controller) { // if there's an existing controller (previous Service Worker), show the prompt needsRefresh(registration) } else { firstLoad = true } } }) }) let refreshing = false // detect controller change and refresh the page navigator.serviceWorker.addEventListener('controllerchange', () => { if (firstLoad) { firstLoad = false return } if (!refreshing) { window.location.reload() refreshing = true } }) } ================================================ FILE: src/lib/helpers/serial-queue.ts ================================================ /** @public */ export class SerialQueue { #chain = Promise.resolve() enqueue(promiseFn: () => Promise): Promise { const result = this.#chain.then(promiseFn) this.#chain = result.catch(() => {}) return result } drain(): Promise { return this.#chain } } ================================================ FILE: src/lib/helpers/test-helpers.ts ================================================ import { expect } from 'vitest' import { type AppStoreNames, getDatabase } from '$lib/db/database.ts' /** @public */ export const clearDatabaseStores = async () => { const db = await getDatabase() for (const storeName of db.objectStoreNames) { await db.clear(storeName) } } /** @public */ export function expectToBeDefined(value: T | undefined): asserts value is T { expect(value).toBeDefined() } /** @public */ export const dbGetAllAndExpectLength = async ( storeName: S, expectedCount: number, message?: string, ) => { const db = await getDatabase() const items = await db.getAll(storeName) expect(items, message).toHaveLength(expectedCount) return items } ================================================ FILE: src/lib/helpers/ui-action.ts ================================================ /** * Executes a UI action that shows a success message upon completion or an error message if the action fails. */ export const createUIAction =

( successMessage: string | false, action: (...params: P) => Promise, ) => { const wrappedAction = async (...params: P): Promise => { try { await action(...params) if (successMessage) { snackbar(successMessage) } } catch (error) { console.error('Error executing UI action:', error) snackbar.unexpectedError(error) } } return wrappedAction } ================================================ FILE: src/lib/helpers/utils/array.ts ================================================ /** @public */ export const toShuffledArray = (input: T[]): T[] => { const output = [...input] for (let i = output.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)) const temp = output[i] as T output[i] = output[j] as T output[j] = temp } return output } ================================================ FILE: src/lib/helpers/utils/assign.ts ================================================ // biome-ignore lint/suspicious/noExplicitAny: needed for inference type Impossible = { [P in K]: never } export const assign = >( target: T, source: S & Impossible>, ): S & T => Object.assign(target, source) ================================================ FILE: src/lib/helpers/utils/clamp.ts ================================================ export const clamp = (num: number, min: number, max: number): number => Math.min(Math.max(num, min), max) ================================================ FILE: src/lib/helpers/utils/debounce.ts ================================================ /** @public */ export const debounce = ) => ReturnType>( fn: Fn, delay: number, ): { (...args: Parameters): void cancel: () => void } => { let timeout: undefined | number const debounceFn = (...args: Parameters) => { clearTimeout(timeout) timeout = setTimeout(fn, delay, ...(args as unknown[])) } debounceFn.cancel = () => { if (timeout) { clearTimeout(timeout) timeout = undefined } } return debounceFn } ================================================ FILE: src/lib/helpers/utils/format-duration.ts ================================================ const twoDigits = (num: number) => num.toString().padStart(2, '0') export const formatDuration = (seconds: number) => { if (!Number.isFinite(seconds)) { return '--:--' } const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const secs = Math.floor(seconds % 60) return `${hours ? `${hours}:` : ''}${twoDigits(minutes)}:${twoDigits(secs)}` } ================================================ FILE: src/lib/helpers/utils/integers.ts ================================================ export const safeInteger = (num: number, fallback = 0): number => { if (Number.isSafeInteger(num)) { return num } return fallback } ================================================ FILE: src/lib/helpers/utils/navigate.ts ================================================ export const navigateToExternal = (url: string) => { window.open(url, '_blank', 'noopener,noreferrer') } ================================================ FILE: src/lib/helpers/utils/text.ts ================================================ import { type StringOrUnknownItem, UNKNOWN_ITEM } from '$lib/library/types.ts' export const truncate = (text: string, length: number): string => { if (text.length <= length) { return text } return `${text.slice(0, length)}...` } export const formatArtists = (artists: readonly StringOrUnknownItem[]): string => artists.filter((artist) => artist !== UNKNOWN_ITEM).join(', ') export const formatNameOrUnknown = (name: StringOrUnknownItem, fallback = m.unknown()): string => name === UNKNOWN_ITEM ? fallback : name export const getItemLanguage = (language: string | undefined): string | undefined => { if (!language) { return } const lang = language.toLowerCase() switch (lang) { case 'jp': case 'jap': case 'japanese': return 'ja' case 'korean': return 'ko' case 'zh-cn': return 'zh-CN' case 'zh-tw': return 'zh-TW' case 'zho': case 'chinese': case 'zh': return 'zh-CN' case 'cantonese': return 'yue' case 'fre': case 'french': return 'fr' case 'esp': case 'spanish': return 'es' case 'eng': case 'english': return 'en' default: return lang } } ================================================ FILE: src/lib/helpers/utils/throttle.ts ================================================ export const throttle = ) => ReturnType>( fn: Fn, delay: number, ): { (...args: Parameters): ReturnType cancel: () => void } => { let wait = false let timeout: undefined | number let prevValue: ReturnType | undefined const throttleFn = (...args: Parameters) => { if (wait) { // prevValue always defined by the // time wait is true return prevValue as ReturnType } const val = fn(...args) prevValue = val wait = true timeout = window.setTimeout(() => { wait = false }, delay) return val } throttleFn.cancel = () => { clearTimeout(timeout) } return throttleFn } ================================================ FILE: src/lib/helpers/utils/ua.ts ================================================ const isMobileRegex = /Android|iPhone|iPad|iPod/i const isMacRegex = /Macintosh|Mac OS X/i const isWindowsRegex = /Windows/i const isAndroidRegex = /Android/i const runOnce = (fn: () => T): (() => T) => { let result: T let hasRun = false return () => { if (hasRun) { return result } result = fn() hasRun = true return result } } /** @public */ export const isMobile = runOnce((): boolean => { if (navigator.userAgentData) { return navigator.userAgentData.mobile } return isMobileRegex.test(navigator.userAgent) }) /** @public */ export const isSafari = runOnce(() => { const ua = navigator.userAgent.toLowerCase() return ua.includes('applewebkit') && !ua.includes('chrome') && !ua.includes('chromium') }) /** @public */ export const isMac = runOnce((): boolean => { if (navigator.userAgentData?.platform) { return navigator.userAgentData.platform === 'macOS' } return isMacRegex.test(navigator.userAgent) }) /** @public */ export const isWindows = runOnce((): boolean => { if (navigator.userAgentData?.platform) { return navigator.userAgentData.platform === 'Windows' } return isWindowsRegex.test(navigator.userAgent) }) export const isAndroid = runOnce((): boolean => { if (navigator.userAgentData) { return navigator.userAgentData.platform === 'Android' } return isAndroidRegex.test(navigator.userAgent) }) export const isChromiumBased = runOnce((): boolean => { // All of our supported Chromium versions will have this property if (navigator.userAgentData) { return navigator.userAgentData.brands.some((brand) => brand.brand.toLowerCase().includes('chromium'), ) } return false }) /** * Returns whether the primary modifier key is pressed. * On Mac this is the Meta key (Cmd), on Windows/Linux it's the Ctrl key. * @public */ export const isPrimaryModifierKey = (event: KeyboardEvent | MouseEvent): boolean => { if (isMac()) { return event.metaKey } return event.ctrlKey } ================================================ FILE: src/lib/helpers/utils/wait.ts ================================================ /** @public */ export const wait = (duration: number): Promise => new Promise((resolve) => { setTimeout(resolve, duration) }) ================================================ FILE: src/lib/helpers/virtualizer.svelte.ts ================================================ import { Virtualizer, type VirtualizerOptions } from '@tanstack/virtual-core' export * from '@tanstack/virtual-core' export function createVirtualizerBase< TScrollElement extends Element | Window, TItemElement extends Element, >( options: () => VirtualizerOptions, ): Virtualizer { const instance = new Virtualizer(options()) let virtualItems = $state(instance.getVirtualItems()) let totalSize = $state(instance.getTotalSize()) const virtualizer = new Proxy(instance, { get(target, prop) { switch (prop) { case 'getVirtualItems': return () => virtualItems case 'getTotalSize': return () => totalSize default: return Reflect.get(target, prop) } }, }) $effect(() => { const cleanup = untrack(() => { const cleanupInner = virtualizer._didMount() return cleanupInner }) return cleanup }) $effect(() => { const resolvedOptions = options() virtualizer.setOptions({ ...resolvedOptions, onChange: (instance, sync) => { instance._willUpdate() virtualItems = instance.getVirtualItems() totalSize = instance.getTotalSize() resolvedOptions.onChange?.(instance, sync) }, }) virtualizer.measure() }) return virtualizer } ================================================ FILE: src/lib/layout-bottom-bar.svelte.ts ================================================ import { createContext } from 'svelte' import { SvelteMap } from 'svelte/reactivity' export interface BottomBarState { bottomBar: Snippet | null abovePlayer: SvelteMap } const [getContext, setContext] = createContext() export const setupOverlaySnippets = () => { const state: BottomBarState = $state({ bottomBar: null, abovePlayer: new SvelteMap(), }) setContext(state) return { get bottomBar(): BottomBarState['bottomBar'] { return state.bottomBar }, get abovePlayer(): Snippet[] { return [...state.abovePlayer.values()] }, } } let counter = 0 export const useSetOverlaySnippet = ( type: 'bottom-bar' | 'above-player', getSnippet: () => Snippet | null, ): void => { const state = getContext() const id = counter counter += 1 $effect.pre(() => { if (type === 'bottom-bar') { state.bottomBar = getSnippet() return () => { state.bottomBar = null } } if (type === 'above-player') { const snippet = getSnippet() if (snippet) { state.abovePlayer.set(id, snippet) } else { state.abovePlayer.delete(id) } return () => { state.abovePlayer.delete(id) } } return undefined }) } ================================================ FILE: src/lib/library/__tests__/play-history.test.ts ================================================ import 'fake-indexeddb/auto' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDatabase } from '$lib/db/database.ts' import { clearDatabaseStores } from '$lib/helpers/test-helpers.ts' import { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts' import { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts' const seedTrack = async (id: number) => { const db = await getDatabase() const trackData: Track = { id, uuid: `track-${id}`, name: `Track ${id}`, artists: ['Artist'], album: 'Album', year: '2026', duration: 180, genre: [], trackNo: 1, trackOf: 1, discNo: 1, discOf: 1, fileName: `track-${id}.mp3`, directory: LEGACY_NO_NATIVE_DIRECTORY, scannedAt: Date.now(), file: new File(['x'], `track-${id}.mp3`, { type: 'audio/mpeg' }), } await db.add('tracks', trackData) } describe('play history actions', () => { afterEach(async () => { vi.restoreAllMocks() await clearDatabaseStores() }) it('keeps only one entry per track id', async () => { let now = 100 vi.spyOn(Date, 'now').mockImplementation(() => { now += 1 return now }) await seedTrack(1) await seedTrack(2) await dbAddToPlayHistory(1) await dbAddToPlayHistory(2) await dbAddToPlayHistory(1) const db = await getDatabase() const entries = await db.getAllFromIndex('playHistory', 'playedAt') expect(entries).toHaveLength(2) expect(entries.filter((entry) => entry.trackId === 1)).toHaveLength(1) expect(entries.map((entry) => entry.trackId)).toEqual([2, 1]) }) it('does not increase history size when replaying the same track', async () => { let now = 200 vi.spyOn(Date, 'now').mockImplementation(() => { now += 1 return now }) await seedTrack(10) await dbAddToPlayHistory(10) await dbAddToPlayHistory(10) await dbAddToPlayHistory(10) const db = await getDatabase() const entries = await db.getAll('playHistory') expect(entries).toHaveLength(1) expect(entries[0]?.trackId).toBe(10) }) it('keeps only the most recent 100 history entries', async () => { let now = 300 vi.spyOn(Date, 'now').mockImplementation(() => { now += 1 return now }) for (let trackId = 1; trackId <= 120; trackId += 1) { await seedTrack(trackId) await dbAddToPlayHistory(trackId) } const db = await getDatabase() const entries = await db.getAllFromIndex('playHistory', 'playedAt') const trackIds = entries.map((entry) => entry.trackId) expect(entries).toHaveLength(100) expect(trackIds[0]).toBe(21) expect(trackIds.at(-1)).toBe(120) expect(trackIds).not.toContain(20) }) }) ================================================ FILE: src/lib/library/__tests__/playlists.test.ts ================================================ import 'fake-indexeddb/auto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDatabase } from '$lib/db/database.ts' import { clearDatabaseStores, dbGetAllAndExpectLength, expectToBeDefined, } from '$lib/helpers/test-helpers.ts' import { createPlaylist, dbAddTracksToPlaylistsWithTx, dbBatchModifyPlaylistsSelection, dbCreatePlaylist, dbRemovePlaylist, dbRemoveTracksFromPlaylistsWithTx, getPlaylistEntriesDatabaseStore, removeTrackEntryFromPlaylist, toggleFavoriteTrack, type UpdatePlaylistOptions, updatePlaylist, } from '$lib/library/playlists-actions.ts' import { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts' import { FAVORITE_PLAYLIST_ID, type UnknownTrack } from '$lib/library/types.ts' vi.mock('$lib/components/snackbar/snackbar', () => ({ snackbar: Object.assign(vi.fn(), { unexpectedError: vi.fn(), }), })) let uuidCounter = 0 vi.stubGlobal('crypto', { randomUUID: vi.fn(() => { uuidCounter += 1 return `test-uuid-${uuidCounter}` }), }) vi.stubGlobal('Date', { now: vi.fn(() => 1_234_567_890), }) let trackCounter = 0 const dbImportTestTrack = async (overrides: Partial = {}): Promise => { trackCounter += 1 const trackData: UnknownTrack = { uuid: `test-track-uuid-${trackCounter}`, name: `Test Track ${trackCounter}`, album: 'Test Album', artists: ['Test Artist'], year: '2023', duration: 180, trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, genre: ['Rock'], file: new File(['test'], 'test.mp3', { type: 'audio/mp3' }) as UnknownTrack['file'], scannedAt: Date.now(), fileName: `test-${trackCounter}.mp3`, directory: 1, ...overrides, } return await dbImportTrack(trackData, undefined) } describe('playlists', () => { beforeEach(async () => { await clearDatabaseStores() trackCounter = 0 uuidCounter = 0 }) afterEach(() => { vi.clearAllMocks() }) describe('playlist creation', () => { it('creates new playlist with all fields', async () => { await dbCreatePlaylist('Test Playlist', 'My description') const db = await getDatabase() const [playlist] = await db.getAll('playlists') expect(playlist?.name).toBe('Test Playlist') expect(playlist?.description).toBe('My description') expect(playlist?.uuid).toBe('test-uuid-1') expect(playlist?.createdAt).toBe(1_234_567_890) }) }) describe('UI wrapper functions', () => { it('creates playlist via UI wrapper', async () => { await createPlaylist('UI Playlist', 'Created via UI') await dbGetAllAndExpectLength('playlists', 1) const db = await getDatabase() const [playlist] = await db.getAll('playlists') expectToBeDefined(playlist) expect(playlist.name).toBe('UI Playlist') expect(playlist.description).toBe('Created via UI') }) it('removes track entry from playlist via UI action', async () => { const trackId = await dbImportTestTrack() const playlistId = await dbCreatePlaylist('Test Playlist', '') const store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlistId], trackIds: [trackId], }) const db = await getDatabase() const [entry] = await db.getAll('playlistEntries') expectToBeDefined(entry) await removeTrackEntryFromPlaylist(entry.id) await dbGetAllAndExpectLength('playlistEntries', 0) }) }) describe('playlist updates', () => { it('updates playlist name and description', async () => { const playlistId = await dbCreatePlaylist('Original Name', 'Original description') const updateOptions: UpdatePlaylistOptions = { id: playlistId, name: 'Updated Name', description: 'Updated description', } const result = await updatePlaylist(updateOptions) expect(result).toBe(true) const db = await getDatabase() const [playlist] = await db.getAll('playlists') expectToBeDefined(playlist) expect(playlist.name).toBe('Updated Name') expect(playlist.description).toBe('Updated description') expect(playlist.uuid).toBe('test-uuid-1') expect(playlist.createdAt).toBe(1_234_567_890) }) it('fails to update non-existent playlist', async () => { const updateOptions: UpdatePlaylistOptions = { id: 999, name: 'Non-existent', description: 'Should fail', } const result = await updatePlaylist(updateOptions) expect(result).toBe(false) }) }) describe('playlist removal', () => { it('removes playlist and associated entries', async () => { const trackId = await dbImportTestTrack() const playlistId = await dbCreatePlaylist('Test Playlist', '') const store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlistId], trackIds: [trackId], }) await dbGetAllAndExpectLength('playlistEntries', 1) await dbRemovePlaylist(playlistId) await dbGetAllAndExpectLength('playlists', 0) await dbGetAllAndExpectLength('playlistEntries', 0) }) it('removes only entries for specific playlist', async () => { const trackId = await dbImportTestTrack() const playlist1Id = await dbCreatePlaylist('Playlist 1', '') const playlist2Id = await dbCreatePlaylist('Playlist 2', '') const store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlist1Id, playlist2Id], trackIds: [trackId], }) await dbGetAllAndExpectLength('playlistEntries', 2) await dbRemovePlaylist(playlist1Id) await dbGetAllAndExpectLength('playlists', 1) const [remainingEntry] = await dbGetAllAndExpectLength('playlistEntries', 1) expectToBeDefined(remainingEntry) expect(remainingEntry.playlistId).toBe(playlist2Id) }) }) describe('playlist entries management', () => { it('adds tracks to multiple playlists', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2' }) const playlist1Id = await dbCreatePlaylist('Playlist 1', '') const playlist2Id = await dbCreatePlaylist('Playlist 2', '') const store = await getPlaylistEntriesDatabaseStore() const changes = await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlist1Id, playlist2Id], trackIds: [track1Id, track2Id], }) expect(changes).toHaveLength(4) await dbGetAllAndExpectLength('playlistEntries', 4) const db = await getDatabase() const entries = await db.getAll('playlistEntries') const playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id) const playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id) expect(playlist1Entries).toHaveLength(2) expect(playlist2Entries).toHaveLength(2) }) it('removes tracks from specific playlists', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2' }) const playlist1Id = await dbCreatePlaylist('Playlist 1', '') const playlist2Id = await dbCreatePlaylist('Playlist 2', '') let store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlist1Id, playlist2Id], trackIds: [track1Id, track2Id], }) await dbGetAllAndExpectLength('playlistEntries', 4) store = await getPlaylistEntriesDatabaseStore() const changes = await dbRemoveTracksFromPlaylistsWithTx(store, { playlistIds: [playlist1Id], trackIds: [track1Id], }) expect(changes).toHaveLength(1) await dbGetAllAndExpectLength('playlistEntries', 3) const db = await getDatabase() const entries = await db.getAll('playlistEntries') const hasTrack1InPlaylist1 = entries.some( (e) => e.playlistId === playlist1Id && e.trackId === track1Id, ) expect(hasTrack1InPlaylist1).toBe(false) }) it('batch modifies playlist selections', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2' }) const playlist1Id = await dbCreatePlaylist('Playlist 1', '') const playlist2Id = await dbCreatePlaylist('Playlist 2', '') const playlist3Id = await dbCreatePlaylist('Playlist 3', '') const store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlist1Id], trackIds: [track1Id, track2Id], }) await dbGetAllAndExpectLength('playlistEntries', 2) const result = await dbBatchModifyPlaylistsSelection({ trackIds: [track1Id, track2Id], playlistsIdsAddTo: [playlist2Id, playlist3Id], playlistsIdsRemoveFrom: [playlist1Id], }) expect(result).toBe(true) await dbGetAllAndExpectLength('playlistEntries', 4) const db = await getDatabase() const entries = await db.getAll('playlistEntries') const playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id) const playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id) const playlist3Entries = entries.filter((e) => e.playlistId === playlist3Id) expect(playlist1Entries).toHaveLength(0) expect(playlist2Entries).toHaveLength(2) expect(playlist3Entries).toHaveLength(2) }) it('returns false when no changes are made', async () => { const result = await dbBatchModifyPlaylistsSelection({ trackIds: [], playlistsIdsAddTo: [], playlistsIdsRemoveFrom: [], }) expect(result).toBe(false) }) }) describe('favorites functionality', () => { it('adds track to favorites', async () => { const trackId = await dbImportTestTrack() await toggleFavoriteTrack(false, trackId) const db = await getDatabase() const [entry] = await db.getAll('playlistEntries') expectToBeDefined(entry) expect(entry.playlistId).toBe(FAVORITE_PLAYLIST_ID) expect(entry.trackId).toBe(trackId) expect(entry.addedAt).toBe(1_234_567_890) }) it('removes track from favorites', async () => { const trackId = await dbImportTestTrack() await toggleFavoriteTrack(false, trackId) await dbGetAllAndExpectLength('playlistEntries', 1) await toggleFavoriteTrack(true, trackId) await dbGetAllAndExpectLength('playlistEntries', 0) }) }) describe('playlist entries data structure', () => { it('creates entries with correct structure', async () => { const trackId = await dbImportTestTrack() const playlistId = await dbCreatePlaylist('Test Playlist', '') const store = await getPlaylistEntriesDatabaseStore() await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlistId], trackIds: [trackId], }) const db = await getDatabase() const [entry] = await db.getAll('playlistEntries') expect(entry).toEqual({ id: expect.any(Number), playlistId, trackId, addedAt: 1_234_567_890, }) }) it('maintains chronological order of entries', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2' }) const playlistId = await dbCreatePlaylist('Test Playlist', '') const store = await getPlaylistEntriesDatabaseStore() vi.mocked(Date.now).mockReturnValueOnce(1000) await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlistId], trackIds: [track1Id], }) vi.mocked(Date.now).mockReturnValueOnce(2000) await dbAddTracksToPlaylistsWithTx(store, { playlistIds: [playlistId], trackIds: [track2Id], }) const db = await getDatabase() const entries = await db.getAll('playlistEntries') entries.sort((a, b) => a.addedAt - b.addedAt) expect(entries).toHaveLength(2) expect(entries[0]?.trackId).toBe(track1Id) expect(entries[0]?.addedAt).toBe(1000) expect(entries[1]?.trackId).toBe(track2Id) expect(entries[1]?.addedAt).toBe(2000) }) }) }) ================================================ FILE: src/lib/library/__tests__/remove.test.ts ================================================ import 'fake-indexeddb/auto' import { beforeEach, describe, expect, it } from 'vitest' import { getDatabase } from '$lib/db/database.ts' import { clearDatabaseStores, dbGetAllAndExpectLength, expectToBeDefined, } from '$lib/helpers/test-helpers.ts' import { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts' import { dbCreatePlaylist } from '$lib/library/playlists-actions.ts' import { dbRemoveAlbum, dbRemoveArtist, dbRemoveTracks } from '$lib/library/remove.ts' import { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts' import type { PlaylistEntry, UnknownTrack } from '$lib/library/types.ts' const dbImportTestTrack = (overrides: Partial = {}): Promise => { const trackData: UnknownTrack = { uuid: crypto.randomUUID(), name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], year: '2023', duration: 180, trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, genre: ['Rock'], file: new File(['test'], 'test.mp3', { type: 'audio/mp3' }), scannedAt: Date.now(), fileName: 'test.mp3', directory: 1, ...overrides, } return dbImportTrack(trackData, undefined) } const createTestPlaylist = async (name = 'Test Playlist'): Promise => dbCreatePlaylist(name, '') const addTrackToPlaylist = async (playlistId: number, trackId: number): Promise => { const db = await getDatabase() const playlistEntry: Omit = { playlistId, trackId, addedAt: Date.now(), } await db.add('playlistEntries', playlistEntry as PlaylistEntry) } const addTracksToPlaylist = async ( playlistId: number, trackIds: readonly number[], ): Promise => { for (const trackId of trackIds) { await addTrackToPlaylist(playlistId, trackId) } } const addTracksToPlayHistory = async (trackIds: readonly number[]): Promise => { for (const trackId of trackIds) { await dbAddToPlayHistory(trackId) } } const expectOnlyTrackWithReferences = async (trackId: number): Promise => { const tracksAfter = await dbGetAllAndExpectLength('tracks', 1) expect(tracksAfter[0]?.id).toBe(trackId) const playlistEntriesAfter = await dbGetAllAndExpectLength('playlistEntries', 1) expect(playlistEntriesAfter[0]?.trackId).toBe(trackId) const playHistoryAfter = await dbGetAllAndExpectLength('playHistory', 1) expect(playHistoryAfter[0]?.trackId).toBe(trackId) } describe('remove functions', () => { beforeEach(async () => { await clearDatabaseStores() }) describe('dbRemoveTracks', () => { it('should remove a track and clean up unused album and artist', async () => { const trackId = await dbImportTestTrack() const db = await getDatabase() // Verify track, album, and artist were created await dbGetAllAndExpectLength('tracks', 1) const albums = await dbGetAllAndExpectLength('albums', 1) expect(albums[0]?.name).toBe('Test Album') const artists = await dbGetAllAndExpectLength('artists', 1) expect(artists[0]?.name).toBe('Test Artist') // Remove the track await dbRemoveTracks([trackId]) // Verify track is removed const removedTrack = await db.get('tracks', trackId) expect(removedTrack).toBeUndefined() // Verify album and artist are also removed (cleanup) await dbGetAllAndExpectLength('albums', 0) await dbGetAllAndExpectLength('artists', 0) }) it('should not remove album or artist if still referenced by other tracks', async () => { // Create two tracks with the same album and artist const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2' }) await dbGetAllAndExpectLength('tracks', 2) await dbGetAllAndExpectLength('albums', 1) await dbGetAllAndExpectLength('artists', 1) await dbRemoveTracks([track1Id]) // Verify only one track is removed const tracksAfter = await dbGetAllAndExpectLength('tracks', 1) expect(tracksAfter[0]?.id).toBe(track2Id) // Verify album and artist are still there await dbGetAllAndExpectLength('albums', 1) await dbGetAllAndExpectLength('artists', 1) }) it('should remove track from all playlists when removing the track', async () => { const trackId = await dbImportTestTrack() const playlistId1 = await createTestPlaylist('Test Playlist 1') const playlistId2 = await createTestPlaylist('Test Playlist 2') await dbGetAllAndExpectLength('playlists', 2) await addTracksToPlaylist(playlistId1, [trackId]) await addTracksToPlaylist(playlistId2, [trackId]) const playlistEntries = await dbGetAllAndExpectLength('playlistEntries', 2) expect(playlistEntries.every((entry) => entry.trackId === trackId)).toBe(true) await dbRemoveTracks([trackId]) await dbGetAllAndExpectLength('playlistEntries', 0) await dbGetAllAndExpectLength('playlists', 2) }) it('should remove deleted tracks from play history and keep unrelated entries', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2' }) await dbAddToPlayHistory(track1Id) await dbAddToPlayHistory(track2Id) await dbGetAllAndExpectLength('playHistory', 2) await dbRemoveTracks([track1Id]) const historyEntries = await dbGetAllAndExpectLength('playHistory', 1) expect(historyEntries[0]?.trackId).toBe(track2Id) }) it('should handle removing non-existent track gracefully', async () => { // Try to remove a track that doesn't exist await expect(dbRemoveTracks([999])).resolves.toBeUndefined() }) it('should ignore duplicate track ids in one removal request', async () => { const trackId = await dbImportTestTrack() const playlistId = await createTestPlaylist() await addTracksToPlaylist(playlistId, [trackId, trackId]) await addTracksToPlayHistory([trackId]) await dbRemoveTracks([trackId, trackId]) await dbGetAllAndExpectLength('tracks', 0) await dbGetAllAndExpectLength('albums', 0) await dbGetAllAndExpectLength('artists', 0) await dbGetAllAndExpectLength('playlistEntries', 0) await dbGetAllAndExpectLength('playHistory', 0) }) it('should remove existing tracks and ignore missing ids in the same request', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1' }) const track2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2', artists: ['Artist 2'], }) const playlistId = await createTestPlaylist() await addTracksToPlaylist(playlistId, [track1Id, track2Id]) await addTracksToPlayHistory([track1Id, track2Id]) await dbRemoveTracks([track1Id, 999]) await expectOnlyTrackWithReferences(track2Id) const albumsAfter = await dbGetAllAndExpectLength('albums', 1) expect(albumsAfter[0]?.name).toBe('Album 2') const artistsAfter = await dbGetAllAndExpectLength('artists', 1) expect(artistsAfter[0]?.name).toBe('Artist 2') }) it('should remove multiple tracks in one operation and clean up shared data once unused', async () => { const track1Id = await dbImportTestTrack({ name: 'Track 1', artists: ['Artist 1'] }) const track2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2', artists: ['Artist 2'], }) const playlistId = await createTestPlaylist() await addTracksToPlaylist(playlistId, [track1Id, track2Id]) await dbRemoveTracks([track1Id, track2Id]) await dbGetAllAndExpectLength('tracks', 0) await dbGetAllAndExpectLength('albums', 0) await dbGetAllAndExpectLength('artists', 0) await dbGetAllAndExpectLength('playlistEntries', 0) await dbGetAllAndExpectLength('playlists', 1) }) it('should return early for empty input', async () => { await expect(dbRemoveTracks([])).resolves.toBeUndefined() }) }) describe('dbRemoveAlbum', () => { it('should remove album and all its tracks', async () => { // Create two tracks with the same album await dbImportTestTrack({ name: 'Track 1' }) await dbImportTestTrack({ name: 'Track 2' }) const albums = await dbGetAllAndExpectLength('albums', 1) const albumId = albums[0]?.id expectToBeDefined(albumId) await dbGetAllAndExpectLength('tracks', 2) await dbRemoveAlbum(albumId) await dbGetAllAndExpectLength('tracks', 0) await dbGetAllAndExpectLength('albums', 0) }) it('should handle removing non-existent album gracefully', async () => { // Try to remove an album that doesn't exist await expect(dbRemoveAlbum(999)).resolves.toBeUndefined() }) it('should clear playlists and play history for removed album tracks only', async () => { const albumTrack1Id = await dbImportTestTrack({ name: 'Track 1' }) const albumTrack2Id = await dbImportTestTrack({ name: 'Track 2' }) const survivorTrackId = await dbImportTestTrack({ name: 'Track 3', album: 'Album 2', artists: ['Artist 2'], }) const playlistId = await createTestPlaylist() await addTracksToPlaylist(playlistId, [albumTrack1Id, albumTrack2Id, survivorTrackId]) await addTracksToPlayHistory([albumTrack1Id, albumTrack2Id, survivorTrackId]) const albums = await dbGetAllAndExpectLength('albums', 2) const albumId = albums.find((album) => album.name === 'Test Album')?.id expectToBeDefined(albumId) await dbRemoveAlbum(albumId) await expectOnlyTrackWithReferences(survivorTrackId) }) it('should keep shared artists that are still referenced by survivor tracks from other albums', async () => { await dbImportTestTrack({ name: 'Album Track 1', album: 'Album 1', artists: ['Shared Artist', 'Album 1 Artist'], }) await dbImportTestTrack({ name: 'Album Track 2', album: 'Album 1', artists: ['Shared Artist'], }) const survivorTrackId = await dbImportTestTrack({ name: 'Survivor Track', album: 'Album 2', artists: ['Shared Artist', 'Album 2 Artist'], }) const albums = await dbGetAllAndExpectLength('albums', 2) const albumId = albums.find((album) => album.name === 'Album 1')?.id expectToBeDefined(albumId) await dbRemoveAlbum(albumId) const tracksAfter = await dbGetAllAndExpectLength('tracks', 1) expect(tracksAfter[0]?.id).toBe(survivorTrackId) const albumsAfter = await dbGetAllAndExpectLength('albums', 1) expect(albumsAfter[0]?.name).toBe('Album 2') const artistsAfter = await dbGetAllAndExpectLength('artists', 2) expect(artistsAfter.map((artist) => artist.name).sort()).toEqual([ 'Album 2 Artist', 'Shared Artist', ]) }) }) describe('dbRemoveArtist', () => { it('should remove artist and all tracks by that artist', async () => { // Create two tracks with the same artist await dbImportTestTrack({ name: 'Track 1', album: 'Album 1', }) await dbImportTestTrack({ name: 'Track 2', album: 'Album 2', }) await dbGetAllAndExpectLength('tracks', 2) const artists = await dbGetAllAndExpectLength('artists', 1) const artistId = artists[0]?.id expectToBeDefined(artistId) await dbRemoveArtist(artistId) await dbGetAllAndExpectLength('tracks', 0) await dbGetAllAndExpectLength('artists', 0) }) it('should handle removing non-existent artist gracefully', async () => { await expect(dbRemoveArtist(999)).resolves.toBeUndefined() }) it('should remove tracks with multiple artists correctly', async () => { await dbImportTestTrack({ artists: ['Artist 1', 'Artist 2'], album: 'Collaboration Album', }) // Another track with only one of the artists const track2Id = await dbImportTestTrack({ name: 'Track 2', artists: ['Artist 2'], album: 'Solo Album', }) const artists = await dbGetAllAndExpectLength('artists', 2) const artist1 = artists.find((a) => a.name === 'Artist 1') expectToBeDefined(artist1?.id) await dbRemoveArtist(artist1.id) const tracksAfter = await dbGetAllAndExpectLength('tracks', 1) expect(tracksAfter[0]?.id, 'Expected Track 2 to remain').toBe(track2Id) const artistsAfter = await dbGetAllAndExpectLength('artists', 1) expect(artistsAfter[0]?.name, 'Expected Artist 2 to remain').toBe('Artist 2') }) it('should clear playlists and play history for removed artist tracks only', async () => { const artistTrack1Id = await dbImportTestTrack({ name: 'Track 1', album: 'Album 1', artists: ['Artist 1'], }) const artistTrack2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2', artists: ['Artist 1'], }) const survivorTrackId = await dbImportTestTrack({ name: 'Track 3', album: 'Album 3', artists: ['Artist 2'], }) const playlistId = await createTestPlaylist() await addTracksToPlaylist(playlistId, [artistTrack1Id, artistTrack2Id, survivorTrackId]) await addTracksToPlayHistory([artistTrack1Id, artistTrack2Id, survivorTrackId]) const artists = await dbGetAllAndExpectLength('artists', 2) const artistId = artists.find((artist) => artist.name === 'Artist 1')?.id expectToBeDefined(artistId) await dbRemoveArtist(artistId) await expectOnlyTrackWithReferences(survivorTrackId) }) it('should keep shared albums that are still referenced by survivor tracks from other artists', async () => { await dbImportTestTrack({ name: 'Artist 1 Track 1', album: 'Shared Album', artists: ['Artist 1'], }) await dbImportTestTrack({ name: 'Artist 1 Track 2', album: 'Artist 1 Album', artists: ['Artist 1'], }) const survivorTrackId = await dbImportTestTrack({ name: 'Artist 2 Survivor', album: 'Shared Album', artists: ['Artist 2'], }) const artists = await dbGetAllAndExpectLength('artists', 2) const artistId = artists.find((artist) => artist.name === 'Artist 1')?.id expectToBeDefined(artistId) await dbRemoveArtist(artistId) const tracksAfter = await dbGetAllAndExpectLength('tracks', 1) expect(tracksAfter[0]?.id).toBe(survivorTrackId) const albumsAfter = await dbGetAllAndExpectLength('albums', 1) expect(albumsAfter[0]?.name).toBe('Shared Album') const artistsAfter = await dbGetAllAndExpectLength('artists', 1) expect(artistsAfter[0]?.name).toBe('Artist 2') }) }) }) ================================================ FILE: src/lib/library/get/__tests__/value.test.ts ================================================ import 'fake-indexeddb/auto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDatabase } from '$lib/db/database.ts' import type { DatabaseChangeDetails } from '$lib/db/events.ts' import { clearDatabaseStores } from '$lib/helpers/test-helpers.ts' import { clearLibraryValueCache, getLibraryValue, LibraryValueNotFoundError, preloadLibraryValue, shouldRefetchLibraryValue, } from '$lib/library/get/value.ts' import { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID } from '$lib/library/types.ts' // Mock crypto.randomUUID for consistent UUIDs vi.stubGlobal('crypto', { randomUUID: vi.fn(() => 'test-uuid-123'), }) // Mock Date.now for consistent timestamps vi.stubGlobal('Date', { now: vi.fn(() => 1_234_567_890), }) describe('getLibraryValue', () => { beforeEach(async () => { await clearDatabaseStores() clearLibraryValueCache() }) afterEach(() => { vi.clearAllMocks() }) describe('tracks', () => { it('should return track data with favorite status false', async () => { const db = await getDatabase() // Insert a track const trackData = { id: 1, name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], uuid: 'track-uuid-1', year: '2023', duration: 180, genre: ['Rock'], trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, file: {} as File, scannedAt: 1_234_567_890, fileName: 'test-track.mp3', directory: 1, } await db.add('tracks', trackData) const result = await getLibraryValue('tracks', 1) expect(result).toEqual({ ...trackData, type: 'track', favorite: false, }) }) it('should return track data with favorite status true when track is in favorites', async () => { const db = await getDatabase() // Insert a track const trackData = { id: 1, name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], uuid: 'track-uuid-1', year: '2023', duration: 180, genre: ['Rock'], trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, file: {} as File, scannedAt: 1_234_567_890, fileName: 'test-track.mp3', directory: 1, } await db.add('tracks', trackData) // Add track to favorites await db.add('playlistEntries', { id: 1, playlistId: FAVORITE_PLAYLIST_ID, trackId: 1, addedAt: 1_234_567_890, }) const result = await getLibraryValue('tracks', 1) expect(result).toEqual({ ...trackData, type: 'track', favorite: true, }) }) it('should throw LibraryValueNotFoundError for non-existent track', async () => { await expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError) }) it('should return undefined for non-existent track when allowEmpty is true', async () => { const result = await getLibraryValue('tracks', 999, true) expect(result).toBeUndefined() }) it('should return cached value on subsequent calls', async () => { const db = await getDatabase() const trackData = { id: 1, name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], uuid: 'track-uuid-1', year: '2023', duration: 180, genre: ['Rock'], trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, file: {} as File, scannedAt: 1_234_567_890, fileName: 'test-track.mp3', directory: 1, } await db.add('tracks', trackData) // First call - should fetch from database const result1 = await getLibraryValue('tracks', 1) // Second call - should return cached value const result2 = await getLibraryValue('tracks', 1) expect(result1).toEqual(result2) }) }) describe('albums', () => { it('should return album data', async () => { const db = await getDatabase() const albumData = { id: 1, name: 'Test Album', uuid: 'album-uuid-1', artists: ['Test Artist'], year: '2023', image: {} as Blob, } await db.add('albums', albumData) const result = await getLibraryValue('albums', 1) expect(result).toEqual({ ...albumData, type: 'album', }) }) it('should throw LibraryValueNotFoundError for non-existent album', async () => { await expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError) }) it('should return undefined for non-existent album when allowEmpty is true', async () => { const result = await getLibraryValue('albums', 999, true) expect(result).toBeUndefined() }) }) describe('artists', () => { it('should return artist data', async () => { const db = await getDatabase() const artistData = { id: 1, name: 'Test Artist', uuid: 'artist-uuid-1', } await db.add('artists', artistData) const result = await getLibraryValue('artists', 1) expect(result).toEqual({ ...artistData, type: 'artist', }) }) it('should throw LibraryValueNotFoundError for non-existent artist', async () => { await expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError) }) it('should return undefined for non-existent artist when allowEmpty is true', async () => { const result = await getLibraryValue('artists', 999, true) expect(result).toBeUndefined() }) }) describe('playlists', () => { it('should return playlist data', async () => { const db = await getDatabase() const playlistData = { id: 1, name: 'Test Playlist', description: '', uuid: 'playlist-uuid-1', createdAt: 1_234_567_890, } await db.add('playlists', playlistData) const result = await getLibraryValue('playlists', 1) expect(result).toEqual({ ...playlistData, type: 'playlist', }) }) it('should return favorite playlist for FAVORITE_PLAYLIST_ID', async () => { const result = await getLibraryValue('playlists', FAVORITE_PLAYLIST_ID) expect(result).toEqual({ type: 'playlist', id: FAVORITE_PLAYLIST_ID, uuid: FAVORITE_PLAYLIST_UUID, description: '', name: 'Favorites', createdAt: 0, }) }) it('should throw LibraryValueNotFoundError for non-existent playlist', async () => { await expect(getLibraryValue('playlists', 999)).rejects.toThrow( LibraryValueNotFoundError, ) }) it('should return undefined for non-existent playlist when allowEmpty is true', async () => { const result = await getLibraryValue('playlists', 999, true) expect(result).toBeUndefined() }) }) describe('preloadLibraryValue', () => { it('should preload track value into cache', async () => { const db = await getDatabase() const trackData = { id: 1, name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], uuid: 'track-uuid-1', year: '2023', duration: 180, genre: ['Rock'], trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, file: {} as File, scannedAt: 1_234_567_890, fileName: 'test-track.mp3', directory: 1, } await db.add('tracks', trackData) // Preload the value await preloadLibraryValue('tracks', 1) // This should now return synchronously from cache const result = getLibraryValue('tracks', 1) // If it's synchronous, it should not be a Promise expect(result).not.toBeInstanceOf(Promise) expect(result).toEqual({ ...trackData, type: 'track', favorite: false, }) }) it('should not throw error for non-existent value', async () => { // Should not throw even though the value doesn't exist await expect(preloadLibraryValue('tracks', 999)).resolves.toBeUndefined() }) }) describe('shouldRefetchLibraryValue', () => { it('should return true when track is updated', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'tracks', operation: 'update', key: 1, }, ] const result = shouldRefetchLibraryValue('tracks', 1, changes) expect(result).toBe(true) }) it('should return true when track favorite status changes', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'playlistEntries', operation: 'add', key: 1, value: { id: 1, playlistId: FAVORITE_PLAYLIST_ID, trackId: 1, addedAt: 1_234_567_890, }, }, ] const result = shouldRefetchLibraryValue('tracks', 1, changes) expect(result).toBe(true) }) it('should return false when unrelated changes occur', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'tracks', operation: 'update', key: 2, }, ] const result = shouldRefetchLibraryValue('tracks', 1, changes) expect(result).toBe(false) }) it('should return true when album is updated', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'albums', operation: 'update', key: 1, }, ] const result = shouldRefetchLibraryValue('albums', 1, changes) expect(result).toBe(true) }) it('should return true when artist is deleted', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'artists', operation: 'delete', key: 1, }, ] const result = shouldRefetchLibraryValue('artists', 1, changes) expect(result).toBe(true) }) it('should return true when playlist is updated', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'playlists', operation: 'update', key: 1, }, ] const result = shouldRefetchLibraryValue('playlists', 1, changes) expect(result).toBe(true) }) it('should return false when no relevant changes occur', () => { const changes: readonly DatabaseChangeDetails[] = [ { storeName: 'albums', operation: 'update', key: 2, }, ] const result = shouldRefetchLibraryValue('tracks', 1, changes) expect(result).toBe(false) }) }) describe('LibraryValueNotFoundError', () => { it('should have correct message and name', () => { const error = new LibraryValueNotFoundError('tracks:1') expect(error.message).toBe('Value not found. Cache key: tracks:1') expect(error.name).toBe('LibraryValueNotFoundError') expect(error).toBeInstanceOf(Error) }) }) describe('concurrent access', () => { it('should handle concurrent requests for same value', async () => { const db = await getDatabase() const trackData = { id: 1, name: 'Test Track', album: 'Test Album', artists: ['Test Artist'], uuid: 'track-uuid-1', year: '2023', duration: 180, genre: ['Rock'], trackNo: 1, trackOf: 10, discNo: 1, discOf: 1, file: {} as File, scannedAt: 1_234_567_890, fileName: 'test-track.mp3', directory: 1, } await db.add('tracks', trackData) // Make multiple concurrent requests const promises = [ getLibraryValue('tracks', 1), getLibraryValue('tracks', 1), getLibraryValue('tracks', 1), ] const results = await Promise.all(promises) // All results should be identical expect(results[0]).toEqual(results[1]) expect(results[1]).toEqual(results[2]) expect(results[0]).toEqual({ ...trackData, type: 'track', favorite: false, }) }) }) describe('error handling', () => { it('should handle LibraryValueNotFoundError correctly', async () => { await expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError) await expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError) await expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError) await expect(getLibraryValue('playlists', 999)).rejects.toThrow( LibraryValueNotFoundError, ) }) }) }) ================================================ FILE: src/lib/library/get/ids-queries.ts ================================================ import type { DatabaseChangeDetailsList } from '$lib/db/events.ts' import type { DbChangeActions } from '$lib/db/query/base-query.svelte.ts' import { createPageQuery, type PageQueryOptions, type PageQueryResult, type QueryKey, } from '$lib/db/query/page-query.svelte.ts' import type { LibraryStoreName } from '../types.ts' import { preloadLibraryValue } from './value.ts' export type { PageQueryResult } from '$lib/db/query/page-query.svelte.ts' export type { QueryResult } from '$lib/db/query/query.ts' const preloadLimit = 12 const preloadLibraryListValues = async ( storeName: Store, keys: number[], ) => { const preload = Array.from({ length: Math.min(keys.length, preloadLimit) }, (_, index) => { const id = keys[index] if (id) { return preloadLibraryValue(storeName, id) } return null }) await Promise.all(preload) } const keysListDatabaseChangeHandler = ( storeName: Store, changes: DatabaseChangeDetailsList, { mutate, refetch }: DbChangeActions, ): void => { let needRefetch = false for (const change of changes) { if (change.storeName !== storeName) { continue } if ( // We have no way of knowing where should the new item be inserted. // So we just refetch the whole list. change.operation === 'add' || // If playlist name changes, order might change as well. (storeName === 'playlists' && change.operation === 'update') ) { needRefetch = true break } if (change.operation === 'delete' && change.key !== undefined) { mutate((value) => { if (!value) { return [] } const index = value.indexOf(change.key) if (index === -1) { return value } value.splice(index, 1) return value }) } } if (needRefetch) { refetch() } } export type LibraryItemKeysPageQueryOptions = Omit< PageQueryOptions, 'onDatabaseChange' > export const createLibraryItemKeysPageQuery = < Store extends LibraryStoreName, const K extends QueryKey, >( storeName: Store, options: LibraryItemKeysPageQueryOptions, ): Promise> => createPageQuery({ ...options, fetcher: async (key, signal) => { const result = await options.fetcher(key, signal) await preloadLibraryListValues(storeName, result) return result }, onDatabaseChange: keysListDatabaseChangeHandler.bind(null, storeName), }) ================================================ FILE: src/lib/library/get/ids.ts ================================================ import type { IDBPIndex } from 'idb' import { type AppDB, type AppIndexNames, getDatabase } from '$lib/db/database.ts' import type { LibraryStoreName } from '../types.ts' export type SortOrder = 'asc' | 'desc' export type LibraryItemSortKey = Exclude< AppIndexNames, symbol > export interface GetLibraryItemIdsOptions { sort: LibraryItemSortKey order?: SortOrder searchTerm?: string searchFn?: (value: AppDB[Store]['value'], term: string) => boolean signal?: AbortSignal } type GetLibraryItemIdsIndex = IDBPIndex< AppDB, [Store], Store, keyof AppDB[Store]['indexes'] & string > const getLibraryItemIdsWithSearchSlow = async ( storeIndex: GetLibraryItemIdsIndex, searchTerm: string, searchFn: (value: AppDB[Store]['value'], term: string) => boolean, signal: AbortSignal | undefined, ) => { const data: number[] = [] for await (const cursor of storeIndex.iterate()) { if (signal?.aborted) { break } if (searchFn(cursor.value, searchTerm)) { data.push(cursor.primaryKey) } } return data } export const getLibraryItemIds = async ( store: Store, options: GetLibraryItemIdsOptions, ): Promise => { const db = await getDatabase() const storeIndex = db.transaction(store).store.index(options.sort) const { searchTerm, searchFn } = options let data: number[] if (searchTerm && searchFn) { data = await getLibraryItemIdsWithSearchSlow( storeIndex, searchTerm, searchFn, options.signal, ) } else { // Fast path data = await db.getAllKeysFromIndex(store, options.sort) } if (options.order === 'desc') { data.reverse() } return data } export const dbGetAlbumTracksIdsByName = async (albumName: string): Promise => { const db = await getDatabase() const tracksIds = await db.getAllKeysFromIndex( 'tracks', 'byAlbumSorted', IDBKeyRange.bound([albumName], [albumName, '\uffff']), ) return tracksIds } export const dbGetArtistTracksIdsByName = async (artistName: string): Promise => { const db = await getDatabase() const tracksIds = await db.getAllKeysFromIndex( 'tracks', 'artists', IDBKeyRange.only(artistName), ) return tracksIds } ================================================ FILE: src/lib/library/get/value-queries.ts ================================================ import { createQuery, type QueryResult } from '$lib/db/query/query.ts' import type { LibraryStoreName } from '../types.ts' import { type GetLibraryValueResult, getLibraryValue, shouldRefetchLibraryValue } from './value.ts' export type { AlbumData, ArtistData, PlaylistData, TrackData } from './value.ts' export interface LibraryValueQueryOptions { allowEmpty?: AllowEmpty } const defineQuery = (storeName: Store) => ( idGetter: () => number, options: LibraryValueQueryOptions = {}, ): QueryResult> => createQuery({ key: idGetter, fetcher: (id) => getLibraryValue(storeName, id, options.allowEmpty), onDatabaseChange: (changes, { refetch }) => { if (shouldRefetchLibraryValue(storeName, idGetter(), changes)) { void refetch() } }, }) type LibraryItemQuery = ReturnType> export const createTrackQuery: LibraryItemQuery<'tracks'> = /* @__PURE__ */ defineQuery('tracks') export const createAlbumQuery: LibraryItemQuery<'albums'> = /* @__PURE__ */ defineQuery('albums') export const createArtistQuery: LibraryItemQuery<'artists'> = /* @__PURE__ */ defineQuery('artists') export const createPlaylistQuery: LibraryItemQuery<'playlists'> = /* @__PURE__ */ defineQuery('playlists') ================================================ FILE: src/lib/library/get/value.ts ================================================ import { WeakLRUCache } from 'weak-lru-cache' import { type DbKey, getDatabase } from '$lib/db/database.ts' import { type DatabaseChangeDetails, onDatabaseChange } from '$lib/db/events.ts' import type { Album, Artist, Playlist, Track } from '$lib/library/types.ts' import { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID, type LibraryStoreName } from '../types.ts' type CacheKey = `${Store}:${string}` const getCacheKey = ( storeName: Store, key: DbKey, ): CacheKey => `${storeName}:${key}` interface QueryConfig { fetch: (id: number) => Promise shouldRefetch: ( itemId: number | undefined, changes: readonly DatabaseChangeDetails[], ) => boolean } const defaultRefreshOnDatabaseChanges = ( storeName: LibraryStoreName, itemId: number | undefined, changes: readonly DatabaseChangeDetails[], ) => { for (const change of changes) { if (change.storeName === storeName) { if (itemId === undefined) { return true } if (change.key === itemId) { return true } } } return false } export interface TrackData extends Track { type: 'track' favorite: boolean } const trackConfig: QueryConfig = { fetch: async (id) => { const db = await getDatabase() const tx = db.transaction(['tracks', 'playlistEntries'], 'readonly') const [item, favorite] = await Promise.all([ tx.objectStore('tracks').get(id), tx .objectStore('playlistEntries') .index('playlistTrack') .get([FAVORITE_PLAYLIST_ID, id]), ]) if (!item) { return undefined } return { ...item, type: 'track', favorite: !!favorite, } as TrackData }, shouldRefetch: (itemId, changes) => { for (const change of changes) { if (change.storeName === 'playlistEntries') { const playlistEntry = change.value if ( playlistEntry.playlistId === FAVORITE_PLAYLIST_ID && itemId === playlistEntry.trackId ) { return true } } if (change.storeName === 'tracks' && change.key === itemId) { return true } } return false }, } const dbGetValue = async ( storeName: Store, type: T, id: number, ) => { const db = await getDatabase() const value = await db.get(storeName, id) if (!value) { return undefined } return { ...value, type, } } export interface AlbumData extends Album { type: 'album' } const albumConfig: QueryConfig = { fetch: (id) => dbGetValue('albums', 'album', id), shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'albums'), } export interface ArtistData extends Artist { type: 'artist' } const artistConfig: QueryConfig = { fetch: (id) => dbGetValue('artists', 'artist', id), shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'artists'), } export interface PlaylistData extends Playlist { type: 'playlist' } const playlistsConfig: QueryConfig = { fetch: (id) => { if (id === FAVORITE_PLAYLIST_ID) { const favoritePlaylist: PlaylistData = { type: 'playlist', id: FAVORITE_PLAYLIST_ID, uuid: FAVORITE_PLAYLIST_UUID, name: m.favorites(), description: '', createdAt: 0, } return Promise.resolve(favoritePlaylist) } return dbGetValue('playlists', 'playlist', id) }, shouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'playlists'), } interface LibraryValueMap { tracks: TrackData albums: AlbumData artists: ArtistData playlists: PlaylistData } type LibraryValue = LibraryValueMap[Store] type LibraryConfigMap = { [Store in LibraryStoreName]: QueryConfig> } const libraryConfigMap = { tracks: trackConfig, albums: albumConfig, artists: artistConfig, playlists: playlistsConfig, } satisfies LibraryConfigMap type LibraryCachedValue = | LibraryValue | Promise | undefined> class LibraryValueCache { #cache = new WeakLRUCache, LibraryCachedValue>({ cacheSize: 10_000, }) get(key: CacheKey) { return this.#cache.getValue(key) as LibraryCachedValue | undefined } set( key: CacheKey, value: LibraryCachedValue | undefined, ) { if (value) { this.#cache.setValue(key, value) } else { this.delete(key) } } delete(key: CacheKey) { this.#cache.delete(key) } clear() { this.#cache.clear() } } // Fast in memory cache for `items`, so we do not need to // call indexed db for every access. // IMPORTANT. Only store whole library items in here. const valueCache = new LibraryValueCache() if (import.meta.env.DEV) { // @ts-expect-error used for debugging globalThis.libraryValueCache = valueCache } if (!import.meta.env.SSR) { onDatabaseChange((changes) => { for (const change of changes) { const { storeName } = change if ( storeName === 'tracks' || storeName === 'albums' || storeName === 'artists' || storeName === 'playlists' ) { if (change.operation === 'delete' || change.operation === 'update') { const cacheKey = getCacheKey(storeName, change.key) valueCache.delete(cacheKey) } } else if (storeName === 'playlistEntries') { const playlistEntry = change.value if (playlistEntry.playlistId === FAVORITE_PLAYLIST_ID) { const cacheKey = getCacheKey('tracks', playlistEntry.trackId) valueCache.delete(cacheKey) } } } }) } export class LibraryValueNotFoundError extends Error { constructor(cacheKey: CacheKey) { super(`Value not found. Cache key: ${cacheKey}`) this.name = 'LibraryValueNotFoundError' } } const assertsValue = ( value: T, allowEmpty: AllowEmpty | undefined, cacheKey: CacheKey, ) => { if (!(value || allowEmpty)) { throw new LibraryValueNotFoundError(cacheKey) } return value } const getCachedOrFetchValue = ( key: CacheKey, fetchValue: () => Promise | undefined>, ): LibraryValue | Promise | undefined> => { const cachedValue = valueCache.get(key) if (cachedValue) { return cachedValue } const promise = fetchValue() .then((value) => { valueCache.set(key, value) return value }) .catch((error) => { valueCache.delete(key) throw error }) valueCache.set(key, promise) return promise } export type GetLibraryValueResult< Store extends LibraryStoreName, AllowEmpty extends boolean = false, > = AllowEmpty extends true ? LibraryValue | undefined : LibraryValue /** @public */ export const getLibraryValue = ( storeName: Store, id: number, allowEmpty?: AllowEmpty, ): Promise> | GetLibraryValueResult => { const key = getCacheKey(storeName, id) const result = getCachedOrFetchValue(key, () => { const config: LibraryConfigMap[Store] = libraryConfigMap[storeName] return config.fetch(id) }) if (result instanceof Promise) { const promiseResult = result.then((value) => assertsValue(value, allowEmpty, key), ) as Promise> return promiseResult } return assertsValue(result, allowEmpty, key) } /** @public */ export const preloadLibraryValue = async ( storeName: LibraryStoreName, id: number, ): Promise => { try { // this will fetch data and store it inside cache await getLibraryValue(storeName, id) } catch { // Ignore } } export const shouldRefetchLibraryValue = ( storeName: LibraryStoreName, id: number | undefined, changes: readonly DatabaseChangeDetails[], ): boolean => { const config = libraryConfigMap[storeName] return config.shouldRefetch(id, changes) } /** @private - Used for testing only */ export const clearLibraryValueCache = () => { valueCache.clear() } ================================================ FILE: src/lib/library/play-history-actions.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { dispatchDatabaseChangedEvent } from '$lib/db/events.ts' import { createUIAction } from '$lib/helpers/ui-action.ts' import type { PlayHistoryEntry } from './types.ts' const PLAY_HISTORY_LIMIT = 100 const notifyPlayHistoryChange = () => { dispatchDatabaseChangedEvent({ storeName: 'playHistory', }) } export const dbAddToPlayHistory = async (trackId: number): Promise => { const db = await getDatabase() const tx = db.transaction(['tracks', 'playHistory'], 'readwrite') const tracksStore = tx.objectStore('tracks') const store = tx.objectStore('playHistory') // Don't add orphaned history records for tracks that are no longer in library. const trackExists = (await tracksStore.count(trackId)) > 0 if (!trackExists) { await tx.done return } const newEntry: Omit = { trackId, playedAt: Date.now(), } const existingKey = await store.index('trackId').getKey(trackId) if (existingKey === undefined) { await store.add(newEntry as PlayHistoryEntry) } else { await store.put({ ...newEntry, id: existingKey, }) } // Keep only the most recent PLAY_HISTORY_LIMIT entries. // Start at newest and jump over the records we keep, then delete the tail. let cursor = await store.index('playedAt').openCursor(null, 'prev') if (cursor !== null) { cursor = await cursor.advance(PLAY_HISTORY_LIMIT) } while (cursor !== null) { await cursor.delete() cursor = await cursor.continue() } await tx.done notifyPlayHistoryChange() } export const dbRemoveFromPlayHistory = async (trackId: number): Promise => { const db = await getDatabase() const tx = db.transaction('playHistory', 'readwrite') const store = tx.objectStore('playHistory') const historyIds = await store.index('trackId').getAllKeys(trackId) await Promise.all(historyIds.map((id) => store.delete(id))) await tx.done notifyPlayHistoryChange() } const dbClearPlayHistory = async (): Promise => { const db = await getDatabase() await db.clear('playHistory') notifyPlayHistoryChange() } export const clearPlayHistory = createUIAction(false, dbClearPlayHistory) ================================================ FILE: src/lib/library/playlists-actions.ts ================================================ import type { IDBPObjectStore } from 'idb' import { type AppDB, getDatabase } from '$lib/db/database.ts' import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts' import { createUIAction } from '$lib/helpers/ui-action.ts' import { truncate } from '$lib/helpers/utils/text.ts' import type { Playlist, PlaylistEntry } from '$lib/library/types.ts' import { FAVORITE_PLAYLIST_ID } from './types.ts' export { FAVORITE_PLAYLIST_ID } from './types.ts' export const dbCreatePlaylist = async ( name: string, description: string, createdAt = Date.now(), ): Promise => { const db = await getDatabase() const newPlaylist: Omit = { name, description, uuid: crypto.randomUUID(), createdAt, } const id = await db.add('playlists', newPlaylist as Playlist) dispatchDatabaseChangedEvent({ operation: 'add', storeName: 'playlists', key: id, }) return id } export const createPlaylist = async (name: string, description: string): Promise => { try { await dbCreatePlaylist(name, description) snackbar( m.libraryPlaylistCreated({ playlistName: truncate(name, 20), }), ) } catch (error) { snackbar.unexpectedError(error) } } export interface UpdatePlaylistOptions { id: number name: string description: string } const dbUpdatePlaylist = async (options: UpdatePlaylistOptions): Promise => { const db = await getDatabase() const id = options.id const tx = db.transaction('playlists', 'readwrite') const existingPlaylist = await tx.store.get(id) invariant(existingPlaylist, 'Playlist not found') const updatedPlaylist: Playlist = { ...existingPlaylist, name: options.name, description: options.description, } await tx.store.put(updatedPlaylist) dispatchDatabaseChangedEvent({ operation: 'update', storeName: 'playlists', key: id, }) } export const updatePlaylist = async (options: UpdatePlaylistOptions): Promise => { try { await dbUpdatePlaylist(options) snackbar({ id: `playlist-updated-${options.id}`, message: m.libraryPlaylistUpdated(truncate(options.name, 20)), }) return true } catch (error) { snackbar.unexpectedError(error) return false } } export const dbRemovePlaylist = async (playlistId: number): Promise => { const db = await getDatabase() const tx = db.transaction(['playlists', 'playlistEntries'], 'readwrite') const entriesStore = tx.objectStore('playlistEntries') const entriesIds = await entriesStore .index('playlistTrack') .getAllKeys(IDBKeyRange.bound([playlistId], [playlistId + 1], false, true)) await Promise.all([ ...entriesIds.map((id) => entriesStore.delete(id)), tx.objectStore('playlists').delete(playlistId), tx.done, ]) // We are not notifying about individual tracks removals // because we are removing the whole playlist dispatchDatabaseChangedEvent({ operation: 'delete', storeName: 'playlists', key: playlistId, }) } export type DbPlaylistEntriesStore = IDBPObjectStore< AppDB, ['playlistEntries'], 'playlistEntries', 'readwrite' > export const getPlaylistEntriesDatabaseStore = async (): Promise => { const db = await getDatabase() const tx = db.transaction('playlistEntries', 'readwrite') const store = tx.objectStore('playlistEntries') return store } export interface AddTracksToPlaylistOptions { playlistIds: readonly number[] trackIds: readonly number[] } export const dbAddTracksToPlaylistsWithTx = ( store: DbPlaylistEntriesStore, options: AddTracksToPlaylistOptions, ) => { const promises = options.trackIds.flatMap((trackId) => options.playlistIds.map(async (playlistId) => { const playlistEntry: Omit = { playlistId, trackId, addedAt: Date.now(), } const playlistEntryId = await store.add(playlistEntry as PlaylistEntry) const change: DatabaseChangeDetails = { storeName: 'playlistEntries', key: playlistEntryId, operation: 'add', value: { ...playlistEntry, id: playlistEntryId, }, } return change }), ) return Promise.all(promises) } interface RemoveTracksFromPlaylistOptions { playlistIds: readonly number[] trackIds: readonly number[] } export const dbRemoveTracksFromPlaylistsWithTx = async ( store: DbPlaylistEntriesStore, options: RemoveTracksFromPlaylistOptions, ) => { const { playlistIds, trackIds } = options const trackIdIndex = store.index('trackId') const changes: DatabaseChangeDetails[] = [] for (const trackId of trackIds) { for await (const cursor of trackIdIndex.iterate(trackId)) { if (playlistIds.includes(cursor.value.playlistId)) { await cursor.delete() changes.push({ storeName: 'playlistEntries', operation: 'delete', key: cursor.primaryKey, value: cursor.value, }) } } } return changes } interface BatchModifyPlaylistSelectionOptions { trackIds: readonly number[] playlistsIdsAddTo: readonly number[] playlistsIdsRemoveFrom: readonly number[] } export const dbBatchModifyPlaylistsSelection = async ( options: BatchModifyPlaylistSelectionOptions, ): Promise => { const store = await getPlaylistEntriesDatabaseStore() const { trackIds, playlistsIdsAddTo, playlistsIdsRemoveFrom } = options const allChanges: DatabaseChangeDetails[] = [] if (playlistsIdsRemoveFrom.length > 0) { const changes = await dbRemoveTracksFromPlaylistsWithTx(store, { playlistIds: playlistsIdsRemoveFrom, trackIds, }) allChanges.push(...changes) } if (playlistsIdsAddTo.length > 0) { const changes = await dbAddTracksToPlaylistsWithTx(store, { playlistIds: playlistsIdsAddTo, trackIds, }) allChanges.push(...changes) } dispatchDatabaseChangedEvent(allChanges) return allChanges.length > 0 } const dbRemoveTrackEntryFromPlaylist = async (playlistEntryId: number): Promise => { const db = await getDatabase() const entry = await db.get('playlistEntries', playlistEntryId) invariant(entry) await db.delete('playlistEntries', entry.id) dispatchDatabaseChangedEvent({ operation: 'delete', storeName: 'playlistEntries', key: entry.id, value: entry, }) } export const removeTrackEntryFromPlaylist = createUIAction( m.libraryTrackRemovedFromPlaylist(), (playlistEntryId: number) => dbRemoveTrackEntryFromPlaylist(playlistEntryId), ) const dbAddTrackToFavorites = async (trackId: number): Promise => { const db = await getDatabase() // Check if already exists to prevent duplicates const existing = await db.getFromIndex('playlistEntries', 'playlistTrack', [ FAVORITE_PLAYLIST_ID, trackId, ]) if (existing) { return } const playlistEntry: Omit = { playlistId: FAVORITE_PLAYLIST_ID, trackId, addedAt: Date.now(), } const key = await db.add('playlistEntries', playlistEntry as PlaylistEntry) dispatchDatabaseChangedEvent({ operation: 'add', storeName: 'playlistEntries', key, value: { ...playlistEntry, id: key, }, }) } const dbRemoveTrackFromFavorites = async (trackId: number): Promise => { const db = await getDatabase() const entry = await db.getFromIndex('playlistEntries', 'playlistTrack', [ FAVORITE_PLAYLIST_ID, trackId, ]) invariant(entry) await db.delete('playlistEntries', entry.id) dispatchDatabaseChangedEvent({ operation: 'delete', storeName: 'playlistEntries', key: entry.id, value: entry, }) } export const toggleFavoriteTrack = async ( shouldBeRemoved: boolean, trackId: number, ): Promise => { try { if (shouldBeRemoved) { await dbRemoveTrackFromFavorites(trackId) } else { await dbAddTrackToFavorites(trackId) } return true } catch (error) { snackbar.unexpectedError(error) return false } } ================================================ FILE: src/lib/library/remove.ts ================================================ import type { IDBPTransaction } from 'idb' import { type AppDB, getDatabase } from '$lib/db/database.ts' import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts' import type { Track } from './types.ts' type TrackOperationsTransaction = IDBPTransaction< AppDB, ('tracks' | 'albums' | 'artists' | 'playlistEntries' | 'playHistory')[], 'readwrite' > const dedupe = (values: readonly T[]): readonly T[] => { if (values.length < 2) { return values } return [...new Set(values)] } const dbRemoveTracksFromPlayHistoryWithTx = async ( tx: TrackOperationsTransaction, trackIds: readonly number[], ): Promise => { const store = tx.objectStore('playHistory') const trackIdIndex = store.index('trackId') let removedAny = false for (const trackId of trackIds) { const historyId = await trackIdIndex.getKey(trackId) if (historyId === undefined) { continue } await store.delete(historyId) removedAny = true } if (!removedAny) { return } return { storeName: 'playHistory' } } const dbRemoveTracksFromAllPlaylistsWithTx = async ( tx: TrackOperationsTransaction, trackIds: readonly number[], ) => { const store = tx.objectStore('playlistEntries') const trackIdIndex = store.index('trackId') const changes: DatabaseChangeDetails[] = [] for (const trackId of trackIds) { const entries = await trackIdIndex.getAll(trackId) await Promise.all(entries.map((entry) => store.delete(entry.id))) changes.push( ...entries.map( (entry): DatabaseChangeDetails => ({ operation: 'delete', storeName: 'playlistEntries', key: entry.id, value: entry, }), ), ) } return changes } const dbRemoveUnusedAlbumsWithTx = async ( tx: TrackOperationsTransaction, albumNames: readonly Track['album'][], ) => { const tracksByAlbum = tx.objectStore('tracks').index('album') const albumsStore = tx.objectStore('albums') const changes: DatabaseChangeDetails[] = [] for (const albumName of dedupe(albumNames)) { const albumNameKey = IDBKeyRange.only(albumName) const tracksWithAlbumCount = await tracksByAlbum.count(albumNameKey) if (tracksWithAlbumCount > 0) { continue } const album = await albumsStore.index('name').get(albumNameKey) if (!album) { continue } await albumsStore.delete(album.id) changes.push({ storeName: 'albums', key: album.id, operation: 'delete', }) } return changes } const dbRemoveUnusedArtistsWithTx = async ( tx: TrackOperationsTransaction, artistNames: readonly string[], ) => { const tracksByArtist = tx.objectStore('tracks').index('artists') const artistsStore = tx.objectStore('artists') const changes: DatabaseChangeDetails[] = [] for (const artistName of dedupe(artistNames)) { const artistNameKey = IDBKeyRange.only(artistName) const tracksWithArtistCount = await tracksByArtist.count(artistNameKey) if (tracksWithArtistCount > 0) { continue } const artist = await artistsStore.index('name').get(artistNameKey) if (!artist) { continue } await artistsStore.delete(artist.id) changes.push({ storeName: 'artists', key: artist.id, operation: 'delete', }) } return changes } export const dbRemoveTracks = async (trackIds: readonly number[]): Promise => { if (trackIds.length === 0) { return } const db = await getDatabase() const tx = db.transaction( ['tracks', 'albums', 'artists', 'playlistEntries', 'playHistory'], 'readwrite', ) const tracksStore = tx.objectStore('tracks') const existingTracks = ( await Promise.all(dedupe(trackIds).map((trackId) => tracksStore.get(trackId))) ).filter((track) => track !== undefined) if (existingTracks.length === 0) { await tx.done return } const existingTrackIds = await Promise.all( existingTracks.map((track) => tracksStore.delete(track.id).then(() => track.id)), ) const [albumChanges, playlistChanges, historyChange, artistChanges] = await Promise.all([ dbRemoveUnusedAlbumsWithTx( tx, existingTracks.map((track) => track.album), ), dbRemoveTracksFromAllPlaylistsWithTx(tx, existingTrackIds), dbRemoveTracksFromPlayHistoryWithTx(tx, existingTrackIds), dbRemoveUnusedArtistsWithTx( tx, existingTracks.flatMap((track) => track.artists), ), ]) const changes = [ ...existingTrackIds.map( (trackId): DatabaseChangeDetails => ({ storeName: 'tracks', operation: 'delete', key: trackId, }), ), historyChange, ...albumChanges, ...artistChanges, ...playlistChanges, ].filter((change) => change !== undefined) await tx.done if (changes.length > 0) { dispatchDatabaseChangedEvent(changes) } } export const dbRemoveAlbum = async (albumId: number): Promise => { const db = await getDatabase() const tx = db.transaction(['albums', 'tracks'], 'readonly') const album = await tx.objectStore('albums').get(albumId) if (!album) { await tx.done return } const tracksIds = await tx.objectStore('tracks').index('album').getAllKeys(album.name) await tx.done // If no tracks references it, it will be deleted automatically await dbRemoveTracks(tracksIds) } export const dbRemoveArtist = async (artistId: number): Promise => { const db = await getDatabase() const tx = db.transaction(['artists', 'tracks'], 'readonly') const artist = await tx.objectStore('artists').get(artistId) if (!artist) { await tx.done return } // Artists is an array, we want to remove all tracks that reference this artist, artist can have other names as well const tracksIds = await tx .objectStore('tracks') .index('artists') .getAllKeys(IDBKeyRange.only(artist.name)) await tx.done // If no tracks references it, it will be deleted automatically await dbRemoveTracks(tracksIds) } ================================================ FILE: src/lib/library/scan-actions/directories.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts' import { lockDatabase } from '$lib/db/lock-database.ts' import { dbRemoveTracks } from '$lib/library/remove.ts' import type { Directory } from '$lib/library/types.ts' import { scanTracks } from './scan-tracks.ts' export interface DirectoryStatus { status: 'child' | 'existing' | 'parent' existingDir: Directory newDirHandle: FileSystemDirectoryHandle } export const checkNewDirectoryStatus = async ( existingDir: Directory, newDirHandle: FileSystemDirectoryHandle, ): Promise => { const paths = await existingDir.handle.resolve(newDirHandle) let status: 'child' | 'existing' | 'parent' | undefined if (paths) { status = paths.length === 0 ? 'existing' : 'child' } else { const parent = await newDirHandle.resolve(existingDir.handle) if (parent) { status = 'parent' } } if (status) { return { status, existingDir, newDirHandle, } } return undefined } const dbImportNewDirectory = async (dirHandle: FileSystemDirectoryHandle): Promise => { const db = await getDatabase() const id = await db.add('directories', { handle: dirHandle, } as Directory) dispatchDatabaseChangedEvent({ key: id, storeName: 'directories', operation: 'add', }) await scanTracks({ action: 'directory-add', dirId: id, dirHandle, }) } export const importNewDirectory = async (handle: FileSystemDirectoryHandle): Promise => { try { await lockDatabase(() => dbImportNewDirectory(handle)) } catch (error) { snackbar.unexpectedError(error) } } export const rescanDirectory = async ( dirId: number, dirHandle: FileSystemDirectoryHandle, ): Promise => { let permission = await dirHandle.queryPermission() if (permission === 'prompt') { permission = await dirHandle.requestPermission() } if (permission !== 'granted') { snackbar(m.settingsGrantDirectoryAccess()) return } try { await lockDatabase(() => scanTracks({ action: 'directory-rescan', dirId, dirHandle, }), ) } catch (error) { snackbar.unexpectedError(error) } } const dbReplaceDirectories = async ( parentDirHandle: FileSystemDirectoryHandle, directoriesIds: readonly number[], ): Promise => { const dirIds = [...directoriesIds] const directoryId = dirIds.pop() // We pick last id and make it the parents new id. invariant(directoryId) const db = await getDatabase() const tx = db.transaction(['directories', 'tracks'], 'readwrite') const newDir: Directory = { id: directoryId, handle: parentDirHandle, } const replaceHandlePromise = tx .objectStore('directories') .put(newDir) .then( (): DatabaseChangeDetails => ({ key: directoryId, storeName: 'directories', operation: 'update', }), ) const promises = dirIds.map(async (existingDirId): Promise => { // Update all tracks to point to the new directory. const updatedTracksPromise = tx .objectStore('tracks') .index('directory') .openCursor(existingDirId) .then(async (c) => { let cursor = c const trackChangeRecords: DatabaseChangeDetails[] = [] while (cursor) { const track = cursor.value track.directory = directoryId await cursor.update(track) cursor = await cursor.continue() trackChangeRecords.push({ key: track.id, storeName: 'tracks', operation: 'update', }) } return trackChangeRecords }) const removedDirectoryPromise = tx .objectStore('directories') .delete(existingDirId) .then( (): DatabaseChangeDetails => ({ key: existingDirId, storeName: 'directories', operation: 'delete', }), ) const result = await Promise.all([removedDirectoryPromise, updatedTracksPromise]) return result.flat() }) const [_, ...changes] = await Promise.all([tx.done, replaceHandlePromise, ...promises]) dispatchDatabaseChangedEvent(changes.flat()) await scanTracks({ action: 'directory-rescan', dirId: directoryId, dirHandle: parentDirHandle, }) } export const replaceDirectories = async ( parentDirHandle: FileSystemDirectoryHandle, dirsIds: number[], ): Promise => { try { await lockDatabase(() => dbReplaceDirectories(parentDirHandle, dirsIds)) } catch (error) { snackbar.unexpectedError(error) } } const dbRemoveDirectory = async (directoryId: number): Promise => { const db = await getDatabase() const tracksToBeRemoved = await db.getAllKeysFromIndex('tracks', 'directory', directoryId) await dbRemoveTracks(tracksToBeRemoved) await db.delete('directories', directoryId) dispatchDatabaseChangedEvent({ key: directoryId, storeName: 'directories', operation: 'delete', }) } export const removeDirectory = async (id: number): Promise => { try { await lockDatabase(() => dbRemoveDirectory(id)) snackbar(m.settingsDirectoryRemoved()) } catch (error) { snackbar.unexpectedError(error) } } const dbImportLegacyFiles = (files: File[]): Promise => scanTracks({ action: 'legacy-files-add', files, }) export const importLegacyFiles = async (files: File[]): Promise => { try { await lockDatabase(() => dbImportLegacyFiles(files)) } catch (error) { snackbar.unexpectedError(error) } } ================================================ FILE: src/lib/library/scan-actions/scan-tracks.ts ================================================ import type { TracksScanOptions } from './scanner/start.ts' export const scanTracks = async (options: TracksScanOptions): Promise => { const snackbarId = 'scan-tracks' snackbar({ id: snackbarId, message: m.settingsPreparingForScan(), controls: false, duration: false, }) const { startTrackScannerWorker } = await import('./scanner/start.ts') const result = await startTrackScannerWorker(options, (data) => { snackbar({ id: snackbarId, message: m.settingsScanInProgress({ current: data.current, total: data.total, }), controls: false, duration: false, }) }) if (result.newlyImported === 0) { snackbar({ id: snackbarId, message: m.settingsScanNoNewTracks(), duration: 2000, }) } else { snackbar({ id: snackbarId, message: m.settingsScanNewOrUpdatedTracks({ newTracks: result.newlyImported, }), duration: 8000, }) } } ================================================ FILE: src/lib/library/scan-actions/scanner/actions.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { type FileEntity, getFileHandlesRecursively } from '$lib/helpers/file-system.ts' import { SerialQueue } from '$lib/helpers/serial-queue.ts' import { dbRemoveTracks } from '$lib/library/remove.ts' import { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts' import { dbImportTrack } from './import-track.ts' import { getArtworkRelatedData } from './parse/format-artwork.ts' import { parseTrackMetadata } from './parse/parse-track.ts' import type { TracksScanMessage, TracksScanOptions } from './types.ts' declare const self: DedicatedWorkerGlobalScope interface TrackEnqueueOptions { scannedAt: number unwrappedFile: File file: FileEntity directoryId: number /** In cases when track already was imported */ trackId?: number uuid?: string } /** * A three-stage pipeline for track ingestion: * 1. [PARSING] - Blocks the caller; processes one file at a time. * 2. [ARTWORK] - Background serial queue for I/O-heavy image processing. * 3. [IMPORT] - Background serial queue for final database writes. * - This "conveyor belt" allows the caller to parse the next track while * previous tracks progress through artwork and import stages concurrently. */ class TrackProcessor { #artworkQueue = new SerialQueue() #importQueue = new SerialQueue() #tracker: StatusTracker #onImportSuccess?: (trackId: number) => void constructor(tracker: StatusTracker, onImportSuccess?: (trackId: number) => void) { this.#tracker = tracker this.#onImportSuccess = onImportSuccess } async parseAndEnqueue(options: TrackEnqueueOptions) { const parsed = await parseTrackMetadata(options.unwrappedFile) if (!parsed) { return } this.#artworkQueue.enqueue(async () => { const artworkData = parsed.imageBlob ? await getArtworkRelatedData(parsed.imageBlob) : undefined this.#importQueue.enqueue(async () => { try { const trackId = await dbImportTrack( { ...parsed.data, ...artworkData, file: options.file, directory: options.directoryId, fileName: options.file.name, scannedAt: options.scannedAt, uuid: options.uuid ?? crypto.randomUUID(), }, options.trackId, ) this.#onImportSuccess?.(trackId) this.#tracker.newlyImported += 1 } catch (err) { console.error(err) } finally { this.#tracker.sendMsg(false) } }) }) } async drain(): Promise { await this.#artworkQueue.drain() await this.#importQueue.drain() } } class StatusTracker { newlyImported = 0 current = 0 total = 0 #pendingTimeout: number | null = null #hasPendingMessage = false #timeId: string constructor(total: number, timeId: string) { this.total = total this.#timeId = timeId console.time(this.#timeId) } sendMsg = (finished: boolean) => { if (finished) { console.timeEnd(this.#timeId) if (this.#pendingTimeout) { self.clearTimeout(this.#pendingTimeout) } } else { if (this.#pendingTimeout) { this.#hasPendingMessage = true return } this.#pendingTimeout = self.setTimeout(() => { this.#pendingTimeout = null if (this.#hasPendingMessage) { this.#hasPendingMessage = false this.#postMessage(false) } }, 200) } this.#postMessage(finished) } #postMessage = (finished: boolean): void => { const message: TracksScanMessage = { finished, count: { newlyImported: this.newlyImported, current: this.current, total: this.total, }, } self.postMessage(message) } } const findTrackByFileHandle = async (handle: FileSystemFileHandle, tracks: Track[]) => { for (const track of tracks) { const isSame = await handle.isSameEntry(track.file as FileSystemFileHandle) if (isSame) { return track } } return null } const findTrackByMixedFileEntity = async (handle: FileEntity, tracks: Track[]) => { for (const track of tracks) { const existingFile = track.file // If file name is changed we can be sure it's not the same file anymore if (existingFile.name !== handle.name) { continue } if ( existingFile instanceof FileSystemFileHandle && handle instanceof FileSystemFileHandle ) { const isSame = await handle.isSameEntry(existingFile) if (isSame) { return track } } // No reliable way to compare two Files, // so we compare their names and size if ( existingFile instanceof File && handle instanceof File && existingFile.size === handle.size ) { return track } } return null } const scanExistingDirectory = async (handles: FileEntity[], directoryId: number) => { const db = await getDatabase() const tracker = new StatusTracker(handles.length, 'SCAN_EXISTING_DIR') const scannedTracksIds = new Set() const processor = new TrackProcessor(tracker, (trackId) => { scannedTracksIds.add(trackId) }) const scannedAt = Date.now() const findTrackFn = directoryId === LEGACY_NO_NATIVE_DIRECTORY ? findTrackByMixedFileEntity : findTrackByFileHandle for (const handle of handles) { tracker.current += 1 try { // Real FS might have multiple files with the same name // but in the database we keep flat structure const possibleExistingTracks = await db.getAllFromIndex('tracks', 'path', [ directoryId, handle.name, ]) const existingTrack = await findTrackFn( // If `LEGACY_NO_NATIVE_DIRECTORY` is used this will be a `File` or `FileSystemFileHandle` // in all other cases it will be a `FileSystemFileHandle` handle as FileSystemFileHandle, possibleExistingTracks, ) const unwrappedFile = handle instanceof File ? handle : await handle.getFile() // File was not modified since last scan if (existingTrack && unwrappedFile.lastModified <= existingTrack.scannedAt) { scannedTracksIds.add(existingTrack.id) tracker.sendMsg(false) continue } await processor.parseAndEnqueue({ unwrappedFile, file: handle, directoryId, trackId: existingTrack?.id, uuid: existingTrack?.uuid, scannedAt, }) } catch { // we ignore errors and just move on to the next track. } } await processor.drain() if (directoryId === LEGACY_NO_NATIVE_DIRECTORY) { tracker.sendMsg(true) return } // After importing is done, we remove tracks that were not scanned // meaning they do not exist in the actual FS anymore const tracksIdsInDirectory = await db.getAllKeysFromIndex('tracks', 'directory', directoryId) const tracksToRemove: number[] = [] for (const trackId of tracksIdsInDirectory) { if (!scannedTracksIds.has(trackId)) { tracksToRemove.push(trackId) } } await dbRemoveTracks(tracksToRemove).catch(console.warn) tracker.sendMsg(true) } const scanNewDirectory = async (handles: FileEntity[], directoryId: number) => { const tracker = new StatusTracker(handles.length, 'SCAN_NEW_DIR') const processor = new TrackProcessor(tracker) const scannedAt = Date.now() for (const handle of handles) { tracker.current += 1 try { const unwrappedFile = handle instanceof File ? handle : await handle.getFile() await processor.parseAndEnqueue({ unwrappedFile, file: handle, directoryId, scannedAt, }) } catch { // we ignore errors and just move on to the next track } } await processor.drain() tracker.sendMsg(true) } export const workerAction = async (options: TracksScanOptions) => { if (options.action === 'directory-add') { const handles = await getFileHandlesRecursively(options.dirHandle) await scanNewDirectory(handles, options.dirId) return } if (options.action === 'directory-rescan') { const handles = await getFileHandlesRecursively(options.dirHandle) await scanExistingDirectory(handles, options.dirId) return } if (options.action === 'legacy-files-add') { await scanExistingDirectory(options.files, LEGACY_NO_NATIVE_DIRECTORY) } } ================================================ FILE: src/lib/library/scan-actions/scanner/import-track.ts ================================================ import type { IDBPTransaction } from 'idb' import { type AppDB, getDatabase } from '$lib/db/database.ts' import { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts' import { type Album, type Artist, type Track, UNKNOWN_ITEM, type UnknownTrack, } from '$lib/library/types.ts' type ImportTrackTx = IDBPTransaction< AppDB, ('tracks' | 'albums' | 'artists' | 'playlistEntries')[], 'readwrite' > const dbImportAlbum = async (tx: ImportTrackTx, track: Track) => { const albumName = track.album const store = tx.objectStore('albums') const existingAlbum = await store.index('name').get(albumName) const updatedAlbum: Omit = existingAlbum ? { ...existingAlbum, artists: [...new Set([...existingAlbum.artists, ...track.artists])].filter( (artist) => artist !== UNKNOWN_ITEM, ), year: existingAlbum.year ?? track.year, image: existingAlbum.image ?? track.image?.full, } : { uuid: crypto.randomUUID(), name: albumName, artists: track.artists, year: track.year, image: track.image?.full, } const albumId = await store.put(updatedAlbum as Album) const change: DatabaseChangeDetails = { storeName: 'albums', key: albumId, operation: existingAlbum ? 'update' : 'add', } return change } const dbImportArtist = async (tx: ImportTrackTx, artistName: string) => { const store = tx.objectStore('artists') const existingArtistId = await store.index('name').getKey(artistName) if (existingArtistId) { return } const newArtist: Omit = { name: artistName, uuid: crypto.randomUUID(), } const artistId = await store.put(newArtist as Artist) const change: DatabaseChangeDetails = { storeName: 'artists', key: artistId, operation: 'add', } return change } const dbImportArtists = (tx: ImportTrackTx, artistNames: string[]) => Promise.all(artistNames.map(async (artist) => dbImportArtist(tx, artist))) export const dbImportTrack = async ( metadata: UnknownTrack, existingTrackId: number | undefined, ): Promise => { const db = await getDatabase() const tx = db.transaction(['tracks', 'albums', 'artists', 'playlistEntries'], 'readwrite') const trackId = await tx.objectStore('tracks').put(metadata as Track, existingTrackId) const track: Track = { ...metadata, id: trackId, } const [albumChange, artistsChanges] = await Promise.all([ dbImportAlbum(tx, track), dbImportArtists(tx, track.artists), tx.done, ]) dispatchDatabaseChangedEvent([ { storeName: 'tracks', key: trackId, operation: existingTrackId === trackId ? 'update' : 'add', }, albumChange, ...artistsChanges, ]) return trackId } ================================================ FILE: src/lib/library/scan-actions/scanner/parse/format-artwork.ts ================================================ import { isSafari as isSafariCheck } from '$lib/helpers/utils/ua.ts' import { getPrimaryColor } from './image-primary-color.ts' const getSmallImageDimensions = ( originalWidth: number, originalHeight: number, ): [width: number, height: number] => { const smallerTarget = Math.min(originalWidth, originalHeight, 100) if (originalWidth === originalHeight) { return [smallerTarget, smallerTarget] } if (originalWidth > originalHeight) { const ratio = originalHeight / originalWidth return [smallerTarget, Math.floor(smallerTarget * ratio)] } const ratio = originalWidth / originalHeight return [Math.floor(smallerTarget * ratio), smallerTarget] } export interface ArtworkRelatedData { image: { optimized: boolean full: Blob small: Blob } primaryColor: number | undefined } const isSafari = isSafariCheck() /** @public */ export const getArtworkRelatedData = async (imageBlob: Blob): Promise => { let bitmap: ImageBitmap | undefined try { bitmap = await createImageBitmap(imageBlob) const [tw, th] = getSmallImageDimensions(bitmap.width, bitmap.height) const canvas = new OffscreenCanvas(tw, th) const ctx = canvas.getContext('2d') invariant(ctx) // Draw smaller image version ctx.drawImage(bitmap, 0, 0, tw, th) const data = ctx.getImageData(0, 0, tw, th).data const primaryColor = getPrimaryColor(data, tw, th) return { image: { optimized: true, full: imageBlob, small: await canvas.convertToBlob({ type: isSafari ? 'image/png' : 'image/webp', quality: 0.7, }), }, primaryColor, } } catch (err) { console.error('Failed to optimize artwork', err) return { image: { optimized: false, full: imageBlob, small: imageBlob, }, primaryColor: undefined, } } finally { bitmap?.close() } } ================================================ FILE: src/lib/library/scan-actions/scanner/parse/image-primary-color.ts ================================================ const SHIFT = 3 // quantize 8-bit -> 5-bit const BINS = 32 * 32 * 32 // 5-bit bins const hueBins = 360 // hue histogram bins (1° each) - increased for better resolution const hueWindow = 24 // accept ±24° around peak hue const alphaThreshold = 240 // ignore semi-transparent const minSat = 0.15 // ignore near-gray - increased to focus on vibrant colors const minVal = 0.15 // ignore near-black - increased to avoid dark noise const whiteSkipV = 0.94 // ignore near-white if also low sat const whiteSkipS = 0.15 const satGamma = 2.2 // stronger accent preference for vibrant colors const valGamma = 0.4 // moderate brightness weight const centerBias = 0.15 // reduced center bias to avoid missing off-center accents // Helper: convert RGB → HSV function rgb2hsv(r: number, g: number, b: number) { const rf = r / 255 const gf = g / 255 const bf = b / 255 const max = Math.max(rf, gf, bf) const min = Math.min(rf, gf, bf) const d = max - min let h = 0 if (d > 0) { if (max === rf) { h = ((gf - bf) / d + (gf < bf ? 6 : 0)) * 60 } else if (max === gf) { h = ((bf - rf) / d + 2) * 60 } else { h = ((rf - gf) / d + 4) * 60 } } const s = max === 0 ? 0 : d / max const v = max return { h, s, v } } /** * Extract a single dominant accent color, ignoring white/gray backgrounds. * Two-pass approach: * 1. Build a hue histogram (weighted by saturation, brightness, center bias). Pick peak hue. * 2. Only count pixels near that hue, then pick most common 5-bit RGB bin. */ export function getPrimaryColor(pixels: Uint8ClampedArray, width: number, height: number): number { // Hue histogram const hueHist = new Float64Array(hueBins) // Gaussian center weighting setup const cx = (width - 1) / 2 const cy = (height - 1) / 2 const sigma = 0.35 * Math.min(width, height) const twoSigma2 = 2 * sigma * sigma // PASS 1: Fill hue histogram let col = 0 let row = 0 for (let i = 0; i < pixels.length; i += 4) { const a = pixels[i + 3] as number if (a < alphaThreshold) { col += 1 if (col >= width) { col = 0 row += 1 } continue } const r = pixels[i] as number const g = pixels[i + 1] as number const b = pixels[i + 2] as number const { h, s, v } = rgb2hsv(r, g, b) // Skip unwanted pixels (white bg, gray, dark) if ((v > whiteSkipV && s < whiteSkipS) || s < minSat || v < minVal) { col += 1 if (col >= width) { col = 0 row += 1 } continue } // Weight = saturation^γ * brightness^γ * centerWeight let w = s ** satGamma * v ** valGamma const dx = col - cx const dy = row - cy const centerW = Math.exp(-(dx * dx + dy * dy) / twoSigma2) w = (1 - centerBias) * w + centerBias * (w * centerW) const bin = Math.floor((h / 360) * hueBins) % hueBins ;(hueHist[bin] as number) += w col += 1 if (col >= width) { col = 0 row += 1 } } // Smooth histogram and pick peak hue const smooth = new Float64Array(hueBins) for (let i = 0; i < hueBins; i += 1) { const im1 = (i - 1 + hueBins) % hueBins const ip1 = (i + 1) % hueBins smooth[i] = 0.5 * (hueHist[im1] as number) + (hueHist[i] as number) + 0.5 * (hueHist[ip1] as number) } let peak = 0 for (let i = 1; i < hueBins; i += 1) { if ((smooth[i] as number) > (smooth[peak] as number)) { peak = i } } const peakHue = (peak + 0.5) * (360 / hueBins) // PASS 2: Build restricted RGB histogram and track actual pixel colors const counts = new Float64Array(BINS) const rSums = new Float64Array(BINS) const gSums = new Float64Array(BINS) const bSums = new Float64Array(BINS) col = 0 row = 0 function inHueWindow(h: number) { let dh = Math.abs(h - peakHue) dh = Math.min(dh, 360 - dh) return dh <= hueWindow } for (let i = 0; i < pixels.length; i += 4) { const a = pixels[i + 3] as number if (a < alphaThreshold) { col += 1 if (col >= width) { col = 0 row += 1 } continue } const r = pixels[i] as number const g = pixels[i + 1] as number const b = pixels[i + 2] as number const { h, s, v } = rgb2hsv(r, g, b) if ((v > whiteSkipV && s < whiteSkipS) || s < minSat || v < minVal) { col += 1 if (col >= width) { col = 0 row += 1 } continue } if (!inHueWindow(h)) { col += 1 if (col >= width) { col = 0 row += 1 } continue } let w = s ** satGamma * v ** valGamma const dx = col - cx const dy = row - cy const centerW = Math.exp(-(dx * dx + dy * dy) / twoSigma2) w = (1 - centerBias) * w + centerBias * (w * centerW) const idx = ((r >> SHIFT) << 10) | ((g >> SHIFT) << 5) | (b >> SHIFT) ;(counts[idx] as number) += w ;(rSums[idx] as number) += r * w ;(gSums[idx] as number) += g * w ;(bSums[idx] as number) += b * w col += 1 if (col >= width) { col = 0 row += 1 } } // Find winner bin let bestIdx = -1 let bestW = -1 for (let idx = 0; idx < BINS; idx += 1) { const w = counts[idx] as number if (w > bestW) { bestW = w bestIdx = idx } } if (bestIdx < 0 || bestW === 0) { return 0xff_00_00_00 } // Calculate weighted average of actual pixel colors in the winning bin const R = Math.round((rSums[bestIdx] as number) / bestW) const G = Math.round((gSums[bestIdx] as number) / bestW) const B = Math.round((bSums[bestIdx] as number) / bestW) return (0xff << 24) | (R << 16) | (G << 8) | B } ================================================ FILE: src/lib/library/scan-actions/scanner/parse/parse-track.ts ================================================ import { parseBuffer } from 'music-metadata' import { type ParsedTrackData, UNKNOWN_ITEM } from '$lib/library/types.ts' // This limit is a bit arbitrary. const FILE_SIZE_LIMIT_300MB = 300 * 1024 * 1024 const artistSeparatorRegex = /,|&/ /** @public */ export const parseTrackMetadata = async ( file: File, ): Promise<{ data: ParsedTrackData; imageBlob?: Blob } | null> => { // Ignore files bigger than limit because of // potential performance issues. if (file.size > FILE_SIZE_LIMIT_300MB) { return null } // Loading whole file into memory all at once is faster than streaming it, // especially on Android where many small FS reads can be very slow. const arrayBuffer = await file.arrayBuffer() const buffer = new Uint8Array(arrayBuffer) const tags = await parseBuffer( buffer, { mimeType: file.type, size: file.size, }, { duration: true, mkvUseIndex: true, skipPostHeaders: true, }, ) const { common } = tags let imageBlob: Blob | undefined const picture = common.picture?.[0] if (picture) { const imageData = new Uint8ClampedArray(picture.data) imageBlob = new Blob([imageData], { type: picture.format }) } const artists = common.artists ?.flatMap((artist) => artist.split(artistSeparatorRegex)) .map((artist) => artist.trim()) ?? [] const trackData: ParsedTrackData = { name: common.title || file.name, album: common.album ?? UNKNOWN_ITEM, artists: artists.length > 0 ? artists : [UNKNOWN_ITEM], genre: common.genre || [], trackNo: common.track.no ?? 0, trackOf: common.track.of ?? 0, discNo: common.disk.no ?? 0, discOf: common.disk.of ?? 0, year: common.year?.toString() ?? UNKNOWN_ITEM, duration: tags.format.duration ?? 0, language: common.language?.trim(), } return { data: trackData, imageBlob, } } ================================================ FILE: src/lib/library/scan-actions/scanner/start.ts ================================================ import type { TracksScanMessage, TracksScanOptions, TracksScanResult } from './types.ts' import TracksWorker from './worker.ts?worker' export type { /** @public */ TracksScanOptions, /** @public */ TracksScanResult, } from './types.ts' /** @public */ export type TrackParsedFn = (totalParsedCount: number) => void /** @public */ export const startTrackScannerWorker = ( options: TracksScanOptions, progress: (data: TracksScanResult) => void, ): Promise => { const { promise, reject, resolve } = Promise.withResolvers() const worker = new TracksWorker() worker.addEventListener('error', reject) worker.addEventListener('message', ({ data }: MessageEvent) => { if (data.finished) { worker.terminate() resolve(data.count) } else { progress(data.count) } }) worker.postMessage(options) return promise } ================================================ FILE: src/lib/library/scan-actions/scanner/types.ts ================================================ import type { FileEntity } from '$lib/helpers/file-system' export interface TracksScanResult { /** Count of many tracks were newly added */ newlyImported: number /** Index of currently scanned track */ current: number /** Total count of tracks */ total: number } export interface TracksScanMessage { finished: boolean count: TracksScanResult } /** @public */ export type TracksScanOptions = | { action: 'directory-add' | 'directory-rescan' dirId: number dirHandle: FileSystemDirectoryHandle } // Used in the browsers which do not support `showDirectoryPicker` | { action: 'legacy-files-add' files: FileEntity[] } ================================================ FILE: src/lib/library/scan-actions/scanner/worker.ts ================================================ /// import { workerAction } from './actions.ts' import type { TracksScanOptions } from './types.ts' declare const self: DedicatedWorkerGlobalScope self.addEventListener('message', async (event: MessageEvent) => { const options = event.data try { await workerAction(options) } catch (err) { self.postMessage({ finished: true, count: { newlyImported: 0, current: 0, total: 0 } }) console.error('[scanner worker]', err) } }) ================================================ FILE: src/lib/library/tracks-queries.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts' export const createTracksCountPageQuery = (): Promise> => createPageQuery({ key: [], fetcher: async () => { const db = await getDatabase() return db.count('tracks') }, onDatabaseChange: (changes, { mutate }) => { let countDiff = 0 for (const change of changes) { if (change.storeName === 'tracks') { const { operation } = change if (operation === 'add') { countDiff += 1 } else if (operation === 'delete') { countDiff -= 1 } } } if (countDiff !== 0) { mutate((v = 0) => v + countDiff) } }, }) ================================================ FILE: src/lib/library/types.ts ================================================ import type { FileEntity } from '$lib/helpers/file-system.ts' export type LibraryStoreName = 'tracks' | 'albums' | 'artists' | 'playlists' /** * Used in browsers where `showDirectoryPicker` is not supported. * `file` field is gonna be `File` in those browsers, * or if user has tracks from previous application version * where directories were not used `FileSystemHandle`. */ export const LEGACY_NO_NATIVE_DIRECTORY = -1 /** Special type of playlist which user cannot modify */ export const FAVORITE_PLAYLIST_ID = -1 export const FAVORITE_PLAYLIST_UUID = 'favorites' /** * Used to represent unknown Artist/Album and other values inside database * Using ~ so when sorting items are always at the end */ export const UNKNOWN_ITEM = '~\0unknown' export type UnknownItem = typeof UNKNOWN_ITEM export type StringOrUnknownItem = (string & {}) | UnknownItem interface BaseMusicItem { id: number name: string } export interface ParsedTrackData { name: string album: StringOrUnknownItem artists: StringOrUnknownItem[] year: StringOrUnknownItem duration: number genre: string[] trackNo: number trackOf: number discNo: number discOf: number language?: string image?: { optimized: boolean small: Blob full: Blob } primaryColor?: number } export interface UnknownTrack extends ParsedTrackData { uuid: string file: FileEntity scannedAt: number fileName: string directory: number } export interface Track extends BaseMusicItem, UnknownTrack {} export interface Album extends BaseMusicItem { uuid: string artists: string[] year?: string image?: Blob } export interface Artist extends BaseMusicItem { uuid: string } export interface Playlist extends BaseMusicItem { uuid: string description: string createdAt: number } export interface PlaylistEntry { id: number playlistId: number trackId: number addedAt: number } export interface PlayHistoryEntry { id: number trackId: number playedAt: number } export interface Directory { id: number handle: FileSystemDirectoryHandle } ================================================ FILE: src/lib/menu-actions/playlists.ts ================================================ import type { MenuItem } from '$lib/components/menu/types.ts' import type { Playlist } from '$lib/library/types.ts' import type { DialogsStore } from '$lib/stores/dialogs/store.svelte.ts' export const getPlaylistMenuItems = (dialogs: DialogsStore, playlist: Playlist): MenuItem[] => [ { label: m.libraryEditPlaylist(), action: () => { dialogs.openDialog('editPlaylist', { id: playlist.id, name: playlist.name, description: playlist.description, }) }, }, { label: m.libraryRemoveFromLibrary(), action: () => { dialogs.openDialog('removeFromLibrary', { type: 'single', id: playlist.id, name: playlist.name, storeName: 'playlists', }) }, }, ] ================================================ FILE: src/lib/stores/dialogs/store.svelte.ts ================================================ import type { ComponentProps } from 'svelte' import type { DialogData, DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte' import type { APP_DIALOGS_COMPONENTS_MAP, AppDialogKey, } from '$lib/components/global-dialogs/dialogs' type DialogOpenProp = ComponentProps< (typeof APP_DIALOGS_COMPONENTS_MAP)[K] >['open'] type StateMap = { [K in AppDialogKey]?: DialogData> } type DialogState = NonNullable type BooleanProps = { [K in AppDialogKey]: DialogState extends boolean ? K : never }[AppDialogKey] export class DialogsStore { #stateMap: StateMap = $state({}) /** Returning new object each time is fine since components are rendered only once on app load */ // biome-ignore lint/suspicious/noExplicitAny: dialog component fails to infer it getAccessor(key: AppDialogKey): DialogOpenAccessor { return { get: () => this.#stateMap[key] ?? null, close: () => { this.closeDialog(key) }, } } openDialog(dialog: K, open?: boolean): void openDialog>(dialog: K, open: DialogState): void openDialog(dialog: K, open: DialogState) { this.#stateMap[dialog] = open ?? true } closeDialog(dialog: K) { this.#stateMap[dialog] = undefined } } ================================================ FILE: src/lib/stores/dialogs/use-store.ts ================================================ import { createContext } from 'svelte' import type { DialogsStore } from './store.svelte.ts' export const [useDialogsStore, setDialogsStoreContext] = createContext() ================================================ FILE: src/lib/stores/main/store.svelte.ts ================================================ import { prefersReducedMotion } from 'svelte/motion' import { MediaQuery } from 'svelte/reactivity' import { supportsChangingAudioVolume } from '$lib/helpers/audio.ts' import { getPersistedValue, persist } from '$lib/helpers/persist.svelte.ts' import { isMobile } from '$lib/helpers/utils/ua.ts' export type AppTheme = 'light' | 'dark' export type AppThemeOption = AppTheme | 'auto' export type AppMotion = 'normal' | 'reduced' export type AppMotionOption = AppMotion | 'auto' export const getPersistedLibrarySplitLayoutEnabled = (): boolean => getPersistedValue('main', 'librarySplitLayoutEnabled', true) export class MainStore { theme: AppThemeOption = $state('auto') #deviceThemeDark = new MediaQuery('(prefers-color-scheme: dark)') get isThemeDark(): boolean { if (this.theme === 'auto') { return this.#deviceThemeDark.current } return this.theme === 'dark' } motion: AppMotionOption = $state('auto') get isReducedMotion(): boolean { const motion = this.motion === 'auto' ? prefersReducedMotion.current : this.motion return motion === 'reduced' } pickColorFromArtwork: boolean = $state(true) customThemePaletteHex: string | null = $state(null) /** * Controls whatever volume slider is visible. * The initial value is false for mobile devices and true for desktop. * User can change this setting. */ volumeSliderEnabled: boolean = $state(supportsChangingAudioVolume() ? !isMobile() : false) appInstallPromptEvent: BeforeInstallPromptEvent | null = $state(null) librarySplitLayoutEnabled: boolean = $state(true) constructor() { persist('main', this, [ 'theme', 'motion', 'pickColorFromArtwork', 'customThemePaletteHex', 'volumeSliderEnabled', 'librarySplitLayoutEnabled', ]) } } ================================================ FILE: src/lib/stores/main/use-store.ts ================================================ import { createContext } from 'svelte' import type { MainStore } from './store.svelte.ts' export const [useMainStore, setMainStoreContext] = createContext() ================================================ FILE: src/lib/stores/player/__test__/audio-loader.test.ts ================================================ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('$lib/helpers/utils/ua', () => ({ isAndroid: () => false, isChromiumBased: () => false, })) import { AudioLoader } from '$lib/stores/player/audio-loader.svelte.ts' const createObjectURL = vi.fn((file: File) => `blob:mock/${file.name}`) const revokeObjectURL = vi.fn() vi.stubGlobal('URL', { createObjectURL, revokeObjectURL, }) const makeHandle = (permission: PermissionState, file = new File([''], 'track.mp3')) => ({ queryPermission: vi.fn().mockResolvedValue(permission), requestPermission: vi.fn().mockResolvedValue(permission), getFile: vi.fn().mockResolvedValue(file), }) as unknown as FileSystemFileHandle const makeSlowHandle = () => { const { promise, resolve } = Promise.withResolvers() const handle = { queryPermission: vi.fn().mockResolvedValue('granted' as PermissionState), getFile: vi.fn().mockReturnValue(promise), } as unknown as FileSystemFileHandle return { handle, resolveFile: resolve } } describe('AudioLoader', () => { afterEach(() => { vi.clearAllMocks() }) it('starts with loading false', () => { expect(new AudioLoader(vi.fn()).loading).toBe(false) }) describe('load()', () => { it('returns loaded and calls onSrc with the blob URL for a plain File', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) expect(await loader.load(-1, new File([''], 'song.mp3'))).toEqual({ status: 'loaded' }) expect(onSrc).toHaveBeenLastCalledWith('blob:mock/song.mp3') expect(loader.loading).toBe(false) }) it('returns loaded for a granted FileSystemFileHandle', async () => { const onSrc = vi.fn() const file = new File([''], 'song.mp3') expect(await new AudioLoader(onSrc).load(1, makeHandle('granted', file))).toEqual({ status: 'loaded', }) expect(onSrc).toHaveBeenLastCalledWith('blob:mock/song.mp3') }) it('returns failed when permission is denied', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) expect(await loader.load(1, makeHandle('denied'))).toEqual({ status: 'failed', reason: 'permission-denied', }) expect(onSrc).not.toHaveBeenCalledWith(expect.stringContaining('blob:')) expect(loader.loading).toBe(false) }) it('returns failed when prompt permission request throws', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) const handle = { queryPermission: vi.fn().mockResolvedValue('prompt' as PermissionState), requestPermission: vi.fn().mockRejectedValue(new Error('user activation required')), getFile: vi.fn(), } as unknown as FileSystemFileHandle expect(await loader.load(1, handle)).toEqual({ status: 'failed', reason: 'permission-denied', }) expect(handle.requestPermission).toHaveBeenCalled() expect(handle.getFile).not.toHaveBeenCalled() expect(loader.loading).toBe(false) }) it('returns failed with reason not-found when getFile throws NotFoundError', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) const handle = { queryPermission: vi.fn().mockResolvedValue('granted' as PermissionState), requestPermission: vi.fn(), getFile: vi.fn().mockRejectedValue(new DOMException('missing', 'NotFoundError')), } as unknown as FileSystemFileHandle expect(await loader.load(1, handle)).toEqual({ status: 'failed', reason: 'not-found', }) expect(loader.loading).toBe(false) }) it('returns failed with reason error when getFile throws unknown error', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const handle = { queryPermission: vi.fn().mockResolvedValue('granted' as PermissionState), requestPermission: vi.fn(), getFile: vi.fn().mockRejectedValue(new Error('boom')), } as unknown as FileSystemFileHandle expect(await loader.load(1, handle)).toEqual({ status: 'failed', reason: 'error', }) expect(consoleError).toHaveBeenCalled() expect(loader.loading).toBe(false) }) it('requests permission when state is prompt', async () => { const handle = makeHandle('prompt') await new AudioLoader(vi.fn()).load(1, handle) expect(handle.requestPermission).toHaveBeenCalled() }) it('revokes previous blob URL before loading a new file', async () => { const loader = new AudioLoader(vi.fn()) await loader.load(-1, new File([''], 'first.mp3')) await loader.load(-1, new File([''], 'second.mp3')) expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/first.mp3') }) it('returns superseded when a newer load races ahead', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) const { handle, resolveFile } = makeSlowHandle() const slow = loader.load(1, handle) const fast = loader.load(-1, new File([''], 'fast.mp3')) resolveFile(new File([''], 'slow.mp3')) const [slowResult, fastResult] = await Promise.all([slow, fast]) expect(slowResult).toEqual({ status: 'superseded' }) expect(fastResult).toEqual({ status: 'loaded' }) expect(onSrc).toHaveBeenLastCalledWith('blob:mock/fast.mp3') }) }) describe('reset()', () => { it('calls onSrc(null), revokes blob URL, and sets loading false', async () => { const onSrc = vi.fn() const loader = new AudioLoader(onSrc) await loader.load(-1, new File([''], 'song.mp3')) loader.reset() expect(onSrc).toHaveBeenLastCalledWith(null) expect(loader.loading).toBe(false) expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/song.mp3') }) it('makes an in-flight load return superseded', async () => { const loader = new AudioLoader(vi.fn()) const { handle, resolveFile } = makeSlowHandle() const pending = loader.load(1, handle) loader.reset() resolveFile(new File([''], 'song.mp3')) expect(await pending).toEqual({ status: 'superseded' }) expect(loader.loading).toBe(false) }) }) }) ================================================ FILE: src/lib/stores/player/__test__/equalizer.test.ts ================================================ import { describe, expect, it, vi } from 'vitest' import { EqualizerStore } from '$lib/stores/player/equalizer.svelte.ts' vi.mock('$lib/helpers/persist.svelte.ts', () => ({ persist: vi.fn(), })) interface MockFilter { connect: ReturnType type: BiquadFilterType frequency: { value: number } Q: { value: number } gain: { value: number } } interface MockAudioContext { state: AudioContextState destination: { connect: ReturnType } resume: ReturnType createBiquadFilter: ReturnType createMediaElementSource: ReturnType } const setupAudioContextMock = (state: AudioContextState = 'suspended') => { const instances: MockAudioContext[] = [] const filters: MockFilter[] = [] const makeSource = () => ({ connect: vi.fn(), }) class AudioContextMock implements MockAudioContext { state: AudioContextState = state destination = { connect: vi.fn() } resume = vi.fn().mockImplementation(() => { this.state = 'running' return Promise.resolve() }) createBiquadFilter = vi.fn(() => { const filter: MockFilter = { connect: vi.fn(), type: 'peaking', frequency: { value: 0 }, Q: { value: 0 }, gain: { value: 0 }, } filters.push(filter) return filter }) createMediaElementSource = vi.fn(() => makeSource()) constructor() { instances.push(this) } } vi.stubGlobal('AudioContext', AudioContextMock) return { AudioContextMock, instances, filters, } } describe('EqualizerStore', () => { it('does not create AudioContext in constructor and initializes lazily in resumeContext', async () => { const { instances, filters } = setupAudioContextMock('suspended') const store = new EqualizerStore({} as HTMLAudioElement) expect(instances).toHaveLength(0) await store.resumeContext() expect(instances).toHaveLength(1) expect(instances[0]?.resume).toHaveBeenCalledTimes(1) expect(filters).toHaveLength(10) }) it('reuses the same AudioContext and does not resume again once running', async () => { const { instances } = setupAudioContextMock('suspended') const store = new EqualizerStore({} as HTMLAudioElement) await store.resumeContext() await store.resumeContext() expect(instances).toHaveLength(1) expect(instances[0]?.resume).toHaveBeenCalledTimes(1) }) it('does not call resume when context is already running', async () => { const { instances } = setupAudioContextMock('running') const store = new EqualizerStore({} as HTMLAudioElement) await store.resumeContext() expect(instances[0]?.resume).not.toHaveBeenCalled() }) it('setBand updates one band and clears selectedPreset', () => { setupAudioContextMock('running') const store = new EqualizerStore({} as HTMLAudioElement) store.applyPreset('bassBoost') store.setBand(0, 7) expect(store.bands[0]).toBe(7) expect(store.selectedPreset).toBeNull() }) it('applyPreset and reset update bands and selectedPreset', () => { setupAudioContextMock('running') const store = new EqualizerStore({} as HTMLAudioElement) store.applyPreset('trebleBoost') expect(store.selectedPreset).toBe('trebleBoost') expect(store.bands).toEqual([0, 0, 0, 0, 0, 0, 2, 4, 5, 6]) store.reset() expect(store.selectedPreset).toBe('flat') expect(store.bands).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) }) }) ================================================ FILE: src/lib/stores/player/__test__/player.svelte.test.ts ================================================ import 'fake-indexeddb/auto' import { flushSync } from 'svelte' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDatabase } from '$lib/db/database.ts' import { clearDatabaseStores, expectToBeDefined } from '$lib/helpers/test-helpers.ts' import { dbRemoveTracks } from '$lib/library/remove' import { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts' import { PlayerStore } from '$lib/stores/player/player.svelte.ts' const queryTracks = vi.hoisted( () => new Map< number, { id: number name: string artists: string[] album: string file: Track['file'] image?: { full: Blob } } >(), ) vi.mock('$lib/library/get/value-queries.ts', () => ({ createTrackQuery: (idGetter: () => number) => ({ get value() { return queryTracks.get(idGetter()) }, get error() { return undefined }, get status() { return 'loaded' }, get loading() { return false }, }), })) vi.mock('$lib/stores/main/use-store.ts', () => ({ useMainStore: () => ({ volumeSliderEnabled: true, }), })) vi.mock('$lib/stores/player/equalizer.svelte.ts', () => ({ EqualizerStore: class { init() {} resumeContext() { return Promise.resolve() } setBand() {} applyPreset() {} reset() {} }, })) const createPlayerInRoot = () => { let player: PlayerStore | undefined const cleanup = $effect.root(() => { player = new PlayerStore() }) expectToBeDefined(player) return { player, [Symbol.dispose]: cleanup, } } class MediaMetadataMock {} class MockAudio { src = '' paused = true currentTime = 0 duration = 0 volume = 1 playbackRate = 1 preservesPitch = true onplay: (() => void) | null = null onpause: (() => void) | null = null onended: (() => void) | null = null ondurationchange: (() => void) | null = null ontimeupdate: (() => void) | null = null play = vi.fn(() => { this.paused = false this.onplay?.() return Promise.resolve() }) pause = vi.fn(() => { this.paused = true this.onpause?.() return Promise.resolve() }) } const getPlayHistoryEntries = async () => { const db = await getDatabase() return db.getAll('playHistory') } const seedTrack = async (id: number) => { const db = await getDatabase() const trackData: Track = { id, uuid: `track-${id}`, name: `Track ${id}`, artists: ['Artist'], album: 'Album', year: '2026', duration: 180, genre: [], trackNo: 1, trackOf: 1, discNo: 1, discOf: 1, fileName: `track-${id}.mp3`, directory: LEGACY_NO_NATIVE_DIRECTORY, scannedAt: Date.now(), file: new File(['x'], `track-${id}.mp3`, { type: 'audio/mpeg' }), } await db.add('tracks', trackData) queryTracks.set(id, { id, name: trackData.name, artists: ['Artist'], album: 'Album', file: trackData.file, }) } describe('PlayerStore', () => { describe('Play history', () => { let mediaSession: { metadata: MediaMetadata | null setActionHandler: ReturnType } let audioInstance: MockAudio | undefined const audioWithCurrentTime = (time: number) => { expectToBeDefined(audioInstance) audioInstance.currentTime = time audioInstance.duration = 180 return audioInstance } beforeEach(async () => { await clearDatabaseStores() mediaSession = { metadata: null, setActionHandler: vi.fn(), } audioInstance = undefined class AudioConstructor extends MockAudio { constructor() { super() audioInstance = this } } vi.stubGlobal('Audio', AudioConstructor) vi.stubGlobal('MediaMetadata', MediaMetadataMock) vi.stubGlobal('navigator', { mediaSession, }) vi.stubGlobal('window', { navigator: { mediaSession, }, }) vi.stubGlobal('location', { origin: 'http://localhost', }) }) afterEach(async () => { await clearDatabaseStores() queryTracks.clear() vi.restoreAllMocks() vi.unstubAllGlobals() }) it('saves final track to history when queue ends with repeat none', async () => { await seedTrack(1) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [1]) expectToBeDefined(audioInstance) const audio = audioWithCurrentTime(120) audio.onended?.() flushSync() const entries = await getPlayHistoryEntries() expect(entries).toHaveLength(1) expect(entries[0]?.trackId).toBe(1) }) it('does not save final track when played time is below threshold', async () => { await seedTrack(2) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [2]) const audio = audioWithCurrentTime(10) audio.onended?.() flushSync() const entries = await getPlayHistoryEntries() expect(entries).toHaveLength(0) }) it('does not save history on ended when repeat is one', async () => { await seedTrack(4) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [4]) flushSync() player.repeat = 'one' const audio = audioWithCurrentTime(179) audio.onended?.() flushSync() const entries = await getPlayHistoryEntries() expect(entries).toHaveLength(0) }) it('saves history when queue is cleared while playing current track', async () => { await seedTrack(5) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [5]) flushSync() audioWithCurrentTime(90) player.clearQueue() flushSync() const entries = await getPlayHistoryEntries() expect(entries[0]?.trackId).toBe(5) }) it('saves history when currently playing track is removed from queue', async () => { await seedTrack(6) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [6, 999]) flushSync() audioWithCurrentTime(90) player.removeFromQueue(0) flushSync() const entries = await getPlayHistoryEntries() expect(entries).toHaveLength(1) expect(entries[0]?.trackId).toBe(6) }) it('does not save history for track removed from library', async () => { await seedTrack(7) await seedTrack(8) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [7, 8, 999]) flushSync() audioWithCurrentTime(90) await dbRemoveTracks([7]) flushSync() const entries = await getPlayHistoryEntries() expect(entries).toHaveLength(0) }) }) describe('General behavior', () => { let mediaSession: { metadata: MediaMetadata | null setActionHandler: ReturnType } let audioInstance: MockAudio | undefined beforeEach(() => { mediaSession = { metadata: null, setActionHandler: vi.fn(), } audioInstance = undefined class AudioConstructor extends MockAudio { constructor() { super() audioInstance = this } } vi.stubGlobal('Audio', AudioConstructor) vi.stubGlobal('MediaMetadata', MediaMetadataMock) vi.stubGlobal('navigator', { mediaSession, }) vi.stubGlobal('window', { navigator: { mediaSession, }, }) vi.stubGlobal('location', { origin: 'http://localhost', }) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) const getMediaActionHandler = ( action: | 'play' | 'pause' | 'previoustrack' | 'nexttrack' | 'seekbackward' | 'seekforward', ) => { const call = mediaSession.setActionHandler.mock.calls.find((c) => c[0] === action) expectToBeDefined(call) const handler = call[1] expectToBeDefined(handler) return handler } it('seekforward and seekbackward media actions clamp audio time', () => { createPlayerInRoot() expectToBeDefined(audioInstance) audioInstance.currentTime = 5 audioInstance.duration = 15 getMediaActionHandler('seekbackward')() expect(audioInstance.currentTime).toBe(0) getMediaActionHandler('seekforward')() expect(audioInstance.currentTime).toBe(10) getMediaActionHandler('seekforward')() expect(audioInstance.currentTime).toBe(15) }) it('toggleRepeat cycles', () => { using pl = createPlayerInRoot() const { player } = pl player.repeat = 'none' expect(player.repeat).toBe('none') player.toggleRepeat() expect(player.repeat).toBe('all') player.toggleRepeat() expect(player.repeat).toBe('one') player.toggleRepeat() expect(player.repeat).toBe('none') }) it('togglePlay does nothing when queue has no active track', () => { using pl = createPlayerInRoot() const { player } = pl expect(player.playing).toBe(false) player.togglePlay(true) expect(player.playing).toBe(false) }) it('seek updates player and audio currentTime', () => { using pl = createPlayerInRoot() const { player } = pl expectToBeDefined(audioInstance) player.seek(42) expect(player.currentTime).toBe(42) expect(audioInstance.currentTime).toBe(42) }) it('preservePitch updates audio pitch-preserve flags', () => { using pl = createPlayerInRoot() const { player } = pl expectToBeDefined(audioInstance) player.preservePitch = false flushSync() expect(audioInstance.preservesPitch).toBe(false) }) it('playTrack on same active track seeks to start', async () => { await seedTrack(3) using pl = createPlayerInRoot() const { player } = pl player.playTrack(0, [3]) flushSync() expectToBeDefined(audioInstance) audioInstance.currentTime = 99 player.playTrack(0) expect(player.currentTime).toBe(0) expect(audioInstance.currentTime).toBe(0) }) }) }) ================================================ FILE: src/lib/stores/player/__test__/queue.test.ts ================================================ import { describe, expect, it } from 'vitest' import { QueueStore } from '$lib/stores/player/queue.svelte.ts' const track = (n: number) => n describe('QueueStore', () => { describe('setTrack', () => { it('sets queue and active index', () => { const q = new QueueStore() q.setTrack(1, [10, 20, 30]) expect(q.itemsIds).toEqual([10, 20, 30]) expect(q.activeTrackIndex).toBe(1) }) it('sets active index to -1 for empty queue', () => { const q = new QueueStore() q.setTrack(0, []) expect(q.activeTrackIndex).toBe(-1) expect(q.isQueueEmpty).toBe(true) }) it('shuffles and pins active index to 0 when shuffle option is set', () => { const q = new QueueStore() q.setTrack(0, [1, 2, 3, 4, 5], { shuffle: true }) expect(q.shuffle).toBe(true) expect(q.activeTrackIndex).toBe(0) expect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]) }) it('disables shuffle when new queue is passed without shuffle option', () => { const q = new QueueStore() q.setTrack(0, [1, 2], { shuffle: true }) q.setTrack(0, [3, 4]) expect(q.shuffle).toBe(false) }) it('changes only active index when no new queue is given', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.setTrack(2) expect(q.itemsIds).toEqual([10, 20, 30]) expect(q.activeTrackIndex).toBe(2) }) }) describe('getNextIndex / getPrevIndex', () => { it('returns next index', () => { const q = new QueueStore() q.setTrack(1, [track(1), track(2), track(3)]) expect(q.getNextIndex()).toBe(2) }) it('wraps to 0 at the end', () => { const q = new QueueStore() q.setTrack(2, [track(1), track(2), track(3)]) expect(q.getNextIndex()).toBe(0) }) it('returns prev index', () => { const q = new QueueStore() q.setTrack(2, [track(1), track(2), track(3)]) expect(q.getPrevIndex()).toBe(1) }) it('wraps to last at the start', () => { const q = new QueueStore() q.setTrack(0, [track(1), track(2), track(3)]) expect(q.getPrevIndex()).toBe(2) }) }) describe('toggleShuffle', () => { it('enables shuffle and moves active track to index 0', () => { const q = new QueueStore() q.setTrack(1, [10, 20, 30]) q.toggleShuffle() expect(q.shuffle).toBe(true) expect(q.activeTrackIndex).toBe(0) expect(q.itemsIds[0]).toBe(20) }) it('shuffled list contains all original IDs', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30, 40, 50]) q.toggleShuffle() expect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([10, 20, 30, 40, 50]) }) it('disables shuffle and restores original order with correct active index', () => { const q = new QueueStore() q.setTrack(1, [10, 20, 30]) q.toggleShuffle() q.toggleShuffle() expect(q.shuffle).toBe(false) expect(q.itemsIds).toEqual([10, 20, 30]) expect(q.activeTrackIndex).toBe(1) }) it('preserves the active track ID when disabling after navigating in shuffle mode', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.toggleShuffle() // Navigate to the second position in the shuffled list const navigatedId = q.itemsIds[1] as number q.setTrack(1) q.toggleShuffle() expect(q.activeTrackId).toBe(navigatedId) expect(q.itemsIds).toEqual([10, 20, 30]) }) it('sets active index to -1 when enabling with no active track', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.removeFromQueue(0) // removes the active track → index becomes -1 q.toggleShuffle() expect(q.shuffle).toBe(true) expect(q.activeTrackIndex).toBe(-1) expect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([20, 30]) }) it('toggles gracefully on an empty queue', () => { const q = new QueueStore() q.toggleShuffle() expect(q.shuffle).toBe(true) expect(q.activeTrackIndex).toBe(-1) expect(q.itemsIds).toEqual([]) q.toggleShuffle() expect(q.shuffle).toBe(false) expect(q.activeTrackIndex).toBe(-1) }) }) describe('addToQueue', () => { it('appends a single track', () => { const q = new QueueStore() q.setTrack(0, [1, 2]) q.addToQueue(3) expect(q.itemsIds).toEqual([1, 2, 3]) }) it('appends multiple tracks', () => { const q = new QueueStore() q.setTrack(0, [1]) q.addToQueue([2, 3]) expect(q.itemsIds).toEqual([1, 2, 3]) }) it('activates index 0 when queue was empty', () => { const q = new QueueStore() expect(q.activeTrackIndex).toBe(-1) q.addToQueue(5) expect(q.activeTrackIndex).toBe(0) }) it('while shuffled, added track is visible immediately and survives toggle-off', () => { const q = new QueueStore() q.setTrack(0, [10, 20]) q.toggleShuffle() q.addToQueue(30) expect(q.itemsIds).toContain(30) q.toggleShuffle() expect(q.itemsIds).toContain(30) }) }) describe('removeFromQueue', () => { it('removes a track by index', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.removeFromQueue(1) expect(q.itemsIds).toEqual([10, 30]) }) it('decrements active index when removing before it', () => { const q = new QueueStore() q.setTrack(2, [10, 20, 30]) q.removeFromQueue(0) expect(q.activeTrackIndex).toBe(1) }) it('sets active index to -1 when removing the active track', () => { const q = new QueueStore() q.setTrack(1, [10, 20, 30]) q.removeFromQueue(1) expect(q.activeTrackIndex).toBe(-1) }) it('does not change active index when removing after it', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.removeFromQueue(2) expect(q.activeTrackIndex).toBe(0) }) it('ignores out-of-bounds index', () => { const q = new QueueStore() q.setTrack(0, [10, 20]) q.removeFromQueue(5) expect(q.itemsIds).toEqual([10, 20]) }) it('removes from both lists when shuffle is enabled', () => { const q = new QueueStore() q.setTrack(0, [10, 20, 30]) q.toggleShuffle() const removedId = q.itemsIds[1] as number q.removeFromQueue(1) expect(q.itemsIds).not.toContain(removedId) q.toggleShuffle() expect(q.itemsIds).not.toContain(removedId) }) }) describe('clearQueue', () => { it('empties the queue and resets active index', () => { const q = new QueueStore() q.setTrack(1, [1, 2, 3]) q.clearQueue() expect(q.itemsIds).toEqual([]) expect(q.activeTrackIndex).toBe(-1) expect(q.isQueueEmpty).toBe(true) }) }) }) ================================================ FILE: src/lib/stores/player/audio-loader.svelte.ts ================================================ import { getDatabase } from '$lib/db/database' import type { FileEntity } from '$lib/helpers/file-system' import { isAndroid, isChromiumBased } from '$lib/helpers/utils/ua' const requestPermission = async (handle: FileSystemHandle) => { let mode = await handle.queryPermission({ mode: 'read' }) if (mode === 'prompt') { try { mode = await handle.requestPermission({ mode: 'read' }) } catch { // `requestPermission` requires a user activation; so swallow the error // and treat it as a denial of permission. } } if (mode === 'granted') { return 'granted' } return 'denied' } const getTrackFileRegular = async (entity: FileSystemFileHandle): Promise => { const permission = await requestPermission(entity) if (permission === 'denied') { return null } return entity.getFile() } const getTrackFileWorkaroundForAndroid = async (directoryId: number, fileName: string) => { const db = await getDatabase() const dir = await db.get('directories', directoryId) if (!dir) { return null } const permission = await requestPermission(dir.handle) if (permission === 'denied') { return null } const fileHandle = await dir.handle.getFileHandle(fileName) return fileHandle.getFile() } const getTrackFile = async (directoryId: number, entity: FileEntity) => { try { let trackFile: File | null = null if (entity instanceof File) { trackFile = entity } // Android on Chromium based browsers has a regression where persisted FileSystemFileHandles // fail with net:ERR_FILE_NOT_FOUND when used with URL.createObjectURL. // https://issues.chromium.org/issues/499064852 else if (isAndroid() && isChromiumBased()) { trackFile = await getTrackFileWorkaroundForAndroid(directoryId, entity.name) } else { trackFile = await getTrackFileRegular(entity) } if (trackFile) { return { status: 'loaded', file: trackFile } as const } return { status: 'permission-denied' } as const } catch (error) { if (error instanceof DOMException && error.name === 'NotFoundError') { return { status: 'not-found' } as const } console.error('Error loading track file:', error) return { status: 'error' } as const } } export class AudioLoader { loading: boolean = $state(false) #onSrc: (src: string | null) => void #currentSrc: string | null = null #current = 0 constructor(onSrc: (src: string | null) => void) { this.#onSrc = onSrc } load = async (directoryId: number, file: FileEntity) => { this.#current += 1 const gen = this.#current this.loading = true this.#clearSrc() const { status: trackStatus, file: trackFile } = await getTrackFile(directoryId, file) if (this.#current !== gen) { return { status: 'superseded' } as const } if (trackStatus !== 'loaded') { this.loading = false return { status: 'failed', reason: trackStatus } as const } this.#currentSrc = URL.createObjectURL(trackFile) this.#onSrc(this.#currentSrc) this.loading = false return { status: 'loaded' } as const } reset = (): void => { this.#current += 1 this.#clearSrc() this.loading = false } #clearSrc = (): void => { if (this.#currentSrc) { URL.revokeObjectURL(this.#currentSrc) this.#currentSrc = null } this.#onSrc(null) } } ================================================ FILE: src/lib/stores/player/equalizer.svelte.ts ================================================ import { persist } from '$lib/helpers/persist.svelte.ts' export const EQ_BANDS = [ { frequency: 32, label: '32 Hz' }, { frequency: 64, label: '64 Hz' }, { frequency: 125, label: '125 Hz' }, { frequency: 250, label: '250 Hz' }, { frequency: 500, label: '500 Hz' }, { frequency: 1000, label: '1 kHz' }, { frequency: 2000, label: '2 kHz' }, { frequency: 4000, label: '4 kHz' }, { frequency: 8000, label: '8 kHz' }, { frequency: 16_000, label: '16 kHz' }, ] as const export type BuiltinEqPresetKey = | 'flat' | 'bassBoost' | 'trebleBoost' | 'rock' | 'pop' | 'jazz' | 'classical' | 'electronic' | 'acoustic' const EQ_PRESET_GAINS: Record = { flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], bassBoost: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0], trebleBoost: [0, 0, 0, 0, 0, 0, 2, 4, 5, 6], rock: [4, 3, 1, 0, -1, 0, 1, 3, 4, 4], pop: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2], jazz: [3, 2, 0, 0, 1, 2, 2, 1, 2, 3], classical: [0, 0, 0, 1, 2, 2, 1, 2, 3, 4], electronic: [5, 4, 2, 0, 1, 2, 1, 3, 4, 4], acoustic: [2, 1, 0, 1, 2, 2, 1, 2, 2, 1], } export const EQ_MIN_GAIN = -12 export const EQ_MAX_GAIN = 12 export class EqualizerStore { enabled: boolean = $state(false) bands: number[] = $state([...EQ_PRESET_GAINS.flat]) selectedPreset: BuiltinEqPresetKey | null = $state('flat') readonly #audio: HTMLAudioElement #audioContext: AudioContext | null = null #filters: BiquadFilterNode[] = [] constructor(audio: HTMLAudioElement) { this.#audio = audio } init = (): void => { persist('equalizer', this, ['enabled', 'bands', 'selectedPreset']) $effect(() => { const enabled = this.enabled const bands = this.bands if (this.#filters.length === 0) { return } invariant(this.#filters.length === bands.length) for (const [index, filter] of this.#filters.entries()) { filter.gain.value = enabled ? (bands[index] ?? 0) : 0 } }) } #ensureAudioGraph = (): AudioContext => { if (this.#audioContext !== null) { return this.#audioContext } const audioContext = new AudioContext() const filters = EQ_BANDS.map(({ frequency }) => { const filter = audioContext.createBiquadFilter() filter.type = 'peaking' filter.frequency.value = frequency filter.Q.value = 1.41 filter.gain.value = 0 return filter }) const source = audioContext.createMediaElementSource(this.#audio) // Chain filters let node: AudioNode = source for (const filter of filters) { node.connect(filter) node = filter } node.connect(audioContext.destination) this.#audioContext = audioContext this.#filters = filters return audioContext } resumeContext = (): Promise => { const audioContext = this.#ensureAudioGraph() if (audioContext.state === 'suspended') { return audioContext.resume() } return Promise.resolve() } setBand = (index: number, gain: number): void => { this.bands[index] = gain this.selectedPreset = null } applyPreset = (name: BuiltinEqPresetKey): void => { this.bands = [...EQ_PRESET_GAINS[name]] this.selectedPreset = name } reset = (): void => { this.applyPreset('flat') } } ================================================ FILE: src/lib/stores/player/player.svelte.ts ================================================ import type { QueryResult } from '$lib/db/query/query.ts' import { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte' import { persist } from '$lib/helpers/persist.svelte.ts' import { clamp } from '$lib/helpers/utils/clamp.ts' import { debounce } from '$lib/helpers/utils/debounce.ts' import { formatArtists, truncate } from '$lib/helpers/utils/text.ts' import { throttle } from '$lib/helpers/utils/throttle.ts' import { createTrackQuery, type TrackData } from '$lib/library/get/value-queries.ts' import { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts' import { AudioLoader } from './audio-loader.svelte.ts' import { EqualizerStore } from './equalizer.svelte.ts' import { type PlayTrackOptions, QueueStore } from './queue.svelte.ts' export type { PlayTrackOptions } export type PlayerRepeat = 'none' | 'one' | 'all' export const PLAYER_PLAYBACK_RATE_MIN = 0.5 export const PLAYER_PLAYBACK_RATE_MAX = 2 export class PlayerStore { readonly #main = useMainStore() readonly #audio = new Audio() readonly #audioLoader = new AudioLoader((src) => { this.#audio.src = src ?? '' }) readonly #queue = new QueueStore() readonly equalizer = new EqualizerStore(this.#audio) repeat: PlayerRepeat = $state('none') playing: boolean = $state(false) muted: boolean = $state(false) #volume: number = $state(100) playbackRate: number = $state(1) preservePitch: boolean = $state(true) get shuffle(): boolean { return this.#queue.shuffle } get itemsIds(): readonly number[] { return this.#queue.itemsIds } get activeTrackIndex(): number { return this.#queue.activeTrackIndex } get isQueueEmpty(): boolean { return this.#queue.isQueueEmpty } loading: boolean = $derived(this.#audioLoader.loading) currentTime: number = $state(0) duration: number = $state(0) get volume(): number { return this.#main.volumeSliderEnabled ? this.#volume : 100 } set volume(value: number) { this.#volume = clamp(value, 0, 100) } #activeTrackQuery: QueryResult = createTrackQuery( () => this.#queue.itemsIds[this.#queue.activeTrackIndex] ?? -1, { allowEmpty: true }, ) activeTrack: TrackData | undefined = $derived(this.#activeTrackQuery.value) #artwork = createManagedArtwork(() => this.activeTrack?.image?.full) artworkSrc: string | undefined = $derived.by(this.#artwork) constructor() { persist('player', this, ['volume', 'repeat', 'muted', 'playbackRate', 'preservePitch']) persist('player', this.#queue, ['shuffle']) this.equalizer.init() const audio = this.#audio // Plain (non-$state) so reads inside the effect don't create subscriptions. let prevTrackId: number | null = null // Debounced to recover from transient undefined during a DB refresh. const scheduleAudioReset = debounce(() => { if (!this.activeTrack) { this.#audioLoader.reset() this.currentTime = 0 this.duration = 0 this.playing = false } }, 100) const trackChanged = (track: TrackData | undefined) => { if (!track) { if (prevTrackId !== null) { this.#savePlayHistory(prevTrackId) prevTrackId = null } scheduleAudioReset() return } if (track.id === prevTrackId) { return } scheduleAudioReset.cancel() if (prevTrackId !== null) { this.#savePlayHistory(prevTrackId) } prevTrackId = track.id this.currentTime = 0 this.duration = 0 void this.#audioLoader.load(track.directory, track.file).then((result) => { if (result.status === 'failed') { const name = truncate(track.name, 30) const errorMap = { 'not-found': m.playerAudioErrorNotFound, 'permission-denied': m.playerAudioErrorPermissionDenied, error: m.playerAudioErrorLoadError, } snackbar({ message: errorMap[result.reason]({ name }), id: 'failed-to-load-audio', duration: 10_000, }) prevTrackId = null this.#queue.setTrack(-1) } }) } $effect(() => { const track = this.activeTrack untrack(() => { trackChanged(track) }) }) // Guarded by loading: prevents play() on an empty/stale src during file fetch. $effect(() => { if (this.#audioLoader.loading) { return } const shouldPlay = this.playing if (audio.paused === !shouldPlay) { return } if (shouldPlay) { void this.equalizer.resumeContext().then(() => audio.play()) } else { void audio.pause() } }) const syncPlayingFromAudio = () => { const audioPlaying = !audio.paused if (audioPlaying !== this.playing) { this.playing = audioPlaying } } audio.onplay = syncPlayingFromAudio audio.onpause = syncPlayingFromAudio audio.onended = () => { if (this.repeat === 'one') { this.seek(0) this.togglePlay(true) return } if ( this.repeat === 'none' && this.#queue.activeTrackIndex === this.#queue.itemsIds.length - 1 ) { const trackId = this.#queue.activeTrackId if (trackId !== null) { this.#savePlayHistory(trackId) } this.togglePlay(false) return } this.playNext() } audio.ondurationchange = () => { this.duration = audio.duration } audio.ontimeupdate = throttle(() => { this.currentTime = audio.currentTime }, 250) const setPlaybackRate = () => { audio.playbackRate = clamp( this.playbackRate, PLAYER_PLAYBACK_RATE_MIN, PLAYER_PLAYBACK_RATE_MAX, ) } audio.onloadedmetadata = () => { // Audio change resets playbackRate setPlaybackRate() } $effect(() => { setPlaybackRate() }) $effect(() => { audio.preservesPitch = this.preservePitch }) $effect(() => { // Humans perceive volume logarithmically // so we adjust the volume to match that perception const k = 0.5 audio.volume = (this.volume / 100) ** k }) $effect(() => { audio.muted = this.muted }) const ms = window.navigator.mediaSession $effect(() => { const track = this.activeTrack if (!track) { ms.metadata = null return } const fallbackArtworkSrc = new URL('/artwork.svg', location.origin).toString() ms.metadata = new MediaMetadata({ title: track.name, artist: formatArtists(track.artists), album: track.album, artwork: [ { src: this.artworkSrc ?? fallbackArtworkSrc, sizes: '512x512', }, ], }) }) // Done for minification purposes. const setAction = ms.setActionHandler.bind(ms) setAction('play', () => this.togglePlay(true)) setAction('pause', () => this.togglePlay(false)) setAction('previoustrack', this.playPrev) setAction('nexttrack', this.playNext) setAction('seekbackward', () => { audio.currentTime = Math.max(audio.currentTime - 10, 0) }) setAction('seekforward', () => { audio.currentTime = Math.min(audio.currentTime + 10, audio.duration) }) // seekto is handled by AudioElement default behavior } #savePlayHistory = (trackId: number): void => { const playedTime = this.#audio.currentTime const totalDuration = this.#audio.duration const percentageThreshold = 0.5 const timeThreshold = 30 const threshold = Math.min(timeThreshold, totalDuration * percentageThreshold) if (totalDuration > 0 && playedTime >= threshold) { void dbAddToPlayHistory(trackId) } } togglePlay = (force?: boolean): void => { if (this.#queue.activeTrackIndex === -1) { return } this.playing = force ?? !this.playing } playNext = (): void => { this.playTrack(this.#queue.getNextIndex()) } playPrev = (): void => { this.playTrack(this.#queue.getPrevIndex()) } playTrack = ( trackIndex: number, queue?: readonly number[], options: PlayTrackOptions = {}, ): void => { const currentTrackId = this.#queue.activeTrackId this.#queue.setTrack(trackIndex, queue, options) const isSameTrack = currentTrackId !== null && this.#queue.activeTrackId === currentTrackId if (isSameTrack) { // Reset time to 0 this.seek(0) } else { // Update ui time instantly, but keep audio.currentTime // until play history is saved. this.currentTime = 0 } this.togglePlay(true) } seek = (time: number): void => { this.currentTime = time this.#audio.currentTime = time } toggleRepeat = (): void => { let { repeat } = this if (repeat === 'none') { repeat = 'all' } else if (repeat === 'all') { repeat = 'one' } else { repeat = 'none' } this.repeat = repeat } toggleShuffle = this.#queue.toggleShuffle addToQueue = this.#queue.addToQueue removeFromQueue = this.#queue.removeFromQueue moveQueueItem = this.#queue.moveQueueItem clearQueue = this.#queue.clearQueue } ================================================ FILE: src/lib/stores/player/queue.svelte.ts ================================================ import { onDatabaseChange } from '$lib/db/events.ts' import { toShuffledArray } from '$lib/helpers/utils/array.ts' export interface PlayTrackOptions { shuffle?: boolean } export class QueueStore { shuffle: boolean = $state(false) #activeTrackIndex = $state(-1) #itemsIdsOriginalOrder = $state([]) #itemsIdsShuffled = $state(null) itemsIds: readonly number[] = $derived( this.#itemsIdsShuffled ? this.#itemsIdsShuffled : this.#itemsIdsOriginalOrder, ) get activeTrackIndex(): number { return this.#activeTrackIndex } get activeTrackId(): number | null { return this.itemsIds[this.#activeTrackIndex] ?? null } get isQueueEmpty(): boolean { return this.itemsIds.length === 0 } constructor() { onDatabaseChange((changes) => { for (const change of changes) { if (change.storeName !== 'tracks' || change.operation !== 'delete') { continue } const index = this.itemsIds.indexOf(change.key) if (index !== -1) { this.#removeByIndex(index, change.key) } } }) } setTrack = ( trackIndex: number, newQueue?: readonly number[], options: PlayTrackOptions = {}, ): void => { if (newQueue) { this.#itemsIdsOriginalOrder = [...newQueue] this.shuffle = options.shuffle ?? false if (this.shuffle) { this.#itemsIdsShuffled = toShuffledArray(this.#itemsIdsOriginalOrder) } else { this.#itemsIdsShuffled = null } } if (this.itemsIds.length === 0) { this.#activeTrackIndex = -1 } else { this.#activeTrackIndex = options.shuffle ? 0 : trackIndex } } getNextIndex = (): number => { const next = this.#activeTrackIndex + 1 return next >= this.itemsIds.length ? 0 : next } getPrevIndex = (): number => { const prev = this.#activeTrackIndex - 1 return prev < 0 ? this.itemsIds.length - 1 : prev } toggleShuffle = (): void => { const activeTrackId = this.itemsIds[this.#activeTrackIndex] ?? -1 this.shuffle = !this.shuffle if (this.shuffle) { this.#itemsIdsShuffled = toShuffledArray(this.#itemsIdsOriginalOrder) const newIndex = this.#itemsIdsShuffled.indexOf(activeTrackId) if (newIndex === -1) { this.#activeTrackIndex = -1 } else { const displaced = this.#itemsIdsShuffled[0] as number this.#itemsIdsShuffled[0] = activeTrackId this.#itemsIdsShuffled[newIndex] = displaced this.#activeTrackIndex = 0 } } else { this.#itemsIdsShuffled = null this.#activeTrackIndex = this.#itemsIdsOriginalOrder.indexOf(activeTrackId) } } addToQueue = (trackId: number | readonly number[]): void => { const ids: readonly number[] = Array.isArray(trackId) ? trackId : [trackId] this.#itemsIdsShuffled?.push(...ids) this.#itemsIdsOriginalOrder.push(...ids) if (this.#activeTrackIndex === -1) { this.#activeTrackIndex = 0 } } removeFromQueue = (index: number): void => { if (index < 0 || index >= this.itemsIds.length) { return } const trackId = this.itemsIds[index] invariant(trackId !== undefined) this.#removeByIndex(index, trackId) } clearQueue = (): void => { this.#itemsIdsOriginalOrder = [] this.#itemsIdsShuffled = null this.#activeTrackIndex = -1 } moveQueueItem = (fromIndex: number, toIndex: number): void => { if ( fromIndex < 0 || fromIndex >= this.itemsIds.length || toIndex < 0 || toIndex >= this.itemsIds.length || fromIndex === toIndex ) { return } // Manual reorder uses the currently visible order as source of truth. if (this.#itemsIdsShuffled) { this.#itemsIdsOriginalOrder = [...this.#itemsIdsShuffled] this.#itemsIdsShuffled = null this.shuffle = false } const movedTrackId = this.#itemsIdsOriginalOrder[fromIndex] if (movedTrackId === undefined) { return } this.#itemsIdsOriginalOrder.splice(fromIndex, 1) this.#itemsIdsOriginalOrder.splice(toIndex, 0, movedTrackId) if (this.#activeTrackIndex === fromIndex) { this.#activeTrackIndex = toIndex return } if (fromIndex < this.#activeTrackIndex && toIndex >= this.#activeTrackIndex) { this.#activeTrackIndex -= 1 return } if (fromIndex > this.#activeTrackIndex && toIndex <= this.#activeTrackIndex) { this.#activeTrackIndex += 1 } } #removeByIndex = (index: number, trackId: number): void => { if (this.#itemsIdsShuffled) { this.#itemsIdsShuffled.splice(index, 1) const originalIndex = this.#itemsIdsOriginalOrder.indexOf(trackId) if (originalIndex !== -1) { this.#itemsIdsOriginalOrder.splice(originalIndex, 1) } } else { this.#itemsIdsOriginalOrder.splice(index, 1) } if (index < this.#activeTrackIndex) { this.#activeTrackIndex -= 1 } else if (index === this.#activeTrackIndex) { this.#activeTrackIndex = -1 } } } ================================================ FILE: src/lib/stores/player/use-store.ts ================================================ import { createContext } from 'svelte' import type { PlayerStore } from './player.svelte.ts' export const [usePlayer, setPlayerStoreContext] = createContext() ================================================ FILE: src/lib/theme.ts ================================================ import { argbFromHex, Cam16, HctSolver, hexFromArgb, // biome-ignore lint/style/noRestrictedImports: Main module for theme utilities } from '@material/material-color-utilities' /** @public */ export type PaletteToken = | 'primary' | 'onPrimary' | 'primaryContainer' | 'onPrimaryContainer' | 'secondary' | 'onSecondary' | 'secondaryContainer' | 'secondaryContainerVariant' | 'onSecondaryContainer' | 'tertiary' | 'onTertiary' | 'tertiaryContainer' | 'onTertiaryContainer' | 'error' | 'onError' | 'errorContainer' | 'onErrorContainer' | 'surface' | 'onSurface' | 'surfaceVariant' | 'onSurfaceVariant' | 'surfaceContainerHighest' | 'surfaceContainerHigh' | 'surfaceContainer' | 'surfaceContainerLow' | 'surfaceContainerLowest' | 'surfaceBright' | 'surfaceDim' | 'outline' | 'outlineVariant' | 'shadow' | 'scrim' | 'inverseSurface' | 'inverseOnSurface' | 'inversePrimary' type Tone = 'a1' | 'a2' | 'a3' | 'n1' | 'n2' | 'error' type PaletteTokenInput = readonly [tone: Tone, light: number, dark: number] type PaletteTokensInputMap = Record const COLOR_TOKENS_GENERATION_MAP: PaletteTokensInputMap = { primary: ['a1', 40, 80], onPrimary: ['a1', 100, 20], primaryContainer: ['a1', 90, 30], onPrimaryContainer: ['a1', 10, 90], secondary: ['a2', 40, 80], onSecondary: ['a2', 100, 20], secondaryContainer: ['a2', 90, 30], secondaryContainerVariant: ['a2', 75, 15], onSecondaryContainer: ['a2', 10, 90], tertiary: ['a3', 40, 80], onTertiary: ['a3', 100, 20], tertiaryContainer: ['a3', 90, 30], onTertiaryContainer: ['a3', 10, 90], error: ['error', 40, 80], onError: ['error', 100, 20], errorContainer: ['error', 90, 30], onErrorContainer: ['error', 10, 90], surface: ['n1', 98, 10], onSurface: ['n1', 10, 90], surfaceVariant: ['n2', 90, 30], onSurfaceVariant: ['n2', 30, 80], surfaceContainerHighest: ['n1', 90, 22], surfaceContainerHigh: ['n1', 92, 17], surfaceContainer: ['n1', 94, 12], surfaceContainerLow: ['n1', 96, 10], surfaceContainerLowest: ['n1', 100, 4], surfaceBright: ['n1', 98, 24], surfaceDim: ['n1', 87, 6], outline: ['n2', 50, 60], outlineVariant: ['n2', 80, 30], shadow: ['n1', 0, 0], scrim: ['n1', 0, 0], inverseSurface: ['n1', 20, 90], inverseOnSurface: ['n1', 95, 10], inversePrimary: ['a1', 80, 40], } const COLOR_TOKENS_GENERATION_ENTRIES = Object.entries(COLOR_TOKENS_GENERATION_MAP) as [ PaletteToken, PaletteTokenInput, ][] const createTonalPalette = (hue: number, chroma: number) => ({ tone: (tone: number) => HctSolver.solveToInt(hue, chroma, tone), }) interface TonalPalette { tone: (argb: number) => number } type ThemeEntry = [key: PaletteToken, hexValue: string] /** @public */ export const getThemePaletteRgbEntries = (argb: number, isDark: boolean): ThemeEntry[] => { const cam16 = Cam16.fromInt(argb) const hue = cam16.hue const chroma = cam16.chroma // We do not use material-color-utilities CorePalette because of large bundle size // and because its color scheme is bit outdated with the current design guidelines const palette: Record = { a1: createTonalPalette(hue, Math.max(48, chroma)), a2: createTonalPalette(hue, 16), a3: createTonalPalette(hue + 60, 24), n1: createTonalPalette(hue, 6), n2: createTonalPalette(hue, 8), error: createTonalPalette(25, 84), } const transformedEntries = COLOR_TOKENS_GENERATION_ENTRIES.map(([key, value]): ThemeEntry => { const [toneName, light, dark] = value const tone = isDark ? dark : light const argbValue = palette[toneName].tone(tone) return [key, hexFromArgb(argbValue)] }) return transformedEntries } const clearThemeCssVariables = (): void => { for (const [key] of COLOR_TOKENS_GENERATION_ENTRIES) { document.documentElement.style.removeProperty(`--color-${key}`) } } const setThemeCssVariables = (argb: number, isDark: boolean): void => { const palette = getThemePaletteRgbEntries(argb, isDark) for (const [key, hex] of palette) { document.documentElement.style.setProperty(`--color-${key}`, hex) } } /** @public */ export const updateThemeCssVariables = ( argbOrHex: number | string | null, isDark: boolean, ): void => { const argb = typeof argbOrHex === 'number' ? argbOrHex : typeof argbOrHex === 'string' ? argbFromHex(argbOrHex) : null if (argb) { setThemeCssVariables(argb, isDark) } else { clearThemeCssVariables() } } ================================================ FILE: src/lib/view-transitions.svelte.ts ================================================ import type { AfterNavigate, OnNavigate } from '@sveltejs/kit' import { browser } from '$app/environment' import { onNavigate } from '$app/navigation' import type { RouteId } from '$app/types' import { getActiveRipplesCount } from './attachments/ripple.ts' import { wait } from './helpers/utils/wait.ts' export type AppViewTransitionType = 'regular' | 'player' | 'library' | 'disabled' export type AppViewTransitionTypeMatcherResult = { view: AppViewTransitionType backwards?: boolean } | null export type AppViewTransitionTypeMatcher = ( to: RouteId, from: RouteId, ) => AppViewTransitionTypeMatcherResult const matchers: (AppViewTransitionTypeMatcher | undefined)[] = [] export const defineViewTransitionMatcher = (callback: AppViewTransitionTypeMatcher): void => { matchers.unshift(callback) // We only care about last and current matcher, // matches from previous routes are not relevant. matchers.length = 2 } type ViewTransitionReadyListener = ( state: 'before-nav' | 'after-nav', match: Exclude, ) => void const listeners = new Set() const notifyListeners: ViewTransitionReadyListener = (state, match) => { for (const listener of listeners) { listener(state, match) } } export const onViewTransitionPrepare = (listener: ViewTransitionReadyListener) => { $effect.pre(() => { listeners.add(listener) return () => { queueMicrotask(() => { listeners.delete(listener) }) } }) } const viewTransitionsUnsupported = !( browser && !!document.startViewTransition && globalThis.ViewTransitionTypeSet ) const resolveView = (nav: OnNavigate | AfterNavigate) => { const to = nav.to?.route.id const from = nav.from?.route.id let customMatch: AppViewTransitionTypeMatcherResult | undefined if (to && from) { for (const matcher of matchers) { const match = matcher?.(to as RouteId, from as RouteId) if (match) { customMatch = match break } } } const goingBackwards = nav.delta ? nav.delta < 0 : false const isBackwards = customMatch?.backwards ?? goingBackwards const view = customMatch?.view ?? 'regular' return { view, isBackwards } } export const setupAppViewTransitions = (disabled: () => boolean): void => { onNavigate(async (nav) => { if (disabled() || viewTransitionsUnsupported) { return } const { promise, resolve } = Promise.withResolvers() if (getActiveRipplesCount() > 0) { // Allow ripple animations to finish before transitioning await wait(175) } const { view, isBackwards } = resolveView(nav) if (view === 'disabled') { return } document.startViewTransition({ update: () => { notifyListeners('before-nav', { view, backwards: isBackwards, }) resolve() return nav.complete.then(() => { notifyListeners('after-nav', { view, backwards: isBackwards, }) }) }, types: [view, isBackwards ? 'backwards' : 'forwards'], }) return promise }) } ================================================ FILE: src/params/libraryEntities.ts ================================================ const libraryEntitiesSlugs = ['tracks', 'albums', 'artists', 'playlists'] as const type LibraryEntitiesSlug = (typeof libraryEntitiesSlugs)[number] const entities = new Set(libraryEntitiesSlugs) export const match = (param): param is LibraryEntitiesSlug => entities.has(param as LibraryEntitiesSlug) ================================================ FILE: src/routes/(app)/(plain)/+layout.svelte ================================================

{@render children()}
================================================ FILE: src/routes/(app)/(plain)/about/+page.svelte ================================================
Logo {m.appName()}
================================================ FILE: src/routes/(app)/(plain)/about/+page.ts ================================================ import type { PageLoad } from './$types.ts' export const load: PageLoad = (): { title: string } => ({ title: m.about(), }) ================================================ FILE: src/routes/(app)/(plain)/settings/+page.svelte ================================================ {#snippet heading(text: string)}
{text}
{/snippet}
{m.settingsDirectories()}
{m.settingsAllDataLocal()}
{#if !isFileSystemAccessSupported} {/if} {#if isDatabasePending}
{m.settingsDbOperationInProgress()}
{/if}
{@render heading(m.settingsAppearance())}
{m.settingsApplicationTheme()}
mainStore.customThemePaletteHex ?? '#000000', (value) => updateMainColor(value) } class="pointer-events-none absolute inset-0 size-full appearance-none opacity-0" />
{m.settingsMotion()}
getLocale(), setLocale} items={languageOptions} key="value" labelKey="name" class="w-40" />
{m.about()}
================================================ FILE: src/routes/(app)/(plain)/settings/+page.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts' import { type Directory, LEGACY_NO_NATIVE_DIRECTORY } from '$lib/library/types.ts' export type DirectoryWithCount = { count: number } & ( | { id: typeof LEGACY_NO_NATIVE_DIRECTORY legacy: true } | (Directory & { legacy?: false }) ) const createDirectoriesPageQuery = () => createPageQuery({ key: [], fetcher: async (): Promise => { const db = await getDatabase() const directories = await db.getAll('directories') const tx = db.transaction('tracks') const directoriesIndex = tx.objectStore('tracks').index('directory') const directoriesWithCount = await Promise.all([ ...directories.map(async (directory) => ({ ...directory, count: await directoriesIndex.count(directory.id), })), directoriesIndex.count(LEGACY_NO_NATIVE_DIRECTORY).then( (count): DirectoryWithCount => ({ id: LEGACY_NO_NATIVE_DIRECTORY, legacy: true, count, }), ), ]) const legacyDir = directoriesWithCount.at(-1) if (legacyDir && legacyDir.count === 0) { // Remove the legacy directory if it has no tracks directoriesWithCount.pop() } return directoriesWithCount }, onDatabaseChange: (changes, { refetch }) => { for (const change of changes) { if (change.storeName === 'tracks' || change.storeName === 'directories') { refetch() break } } }, }) interface LoadResult { directoriesQuery: PageQueryResult title: string } export const load = async (): Promise => { const directories = await createDirectoriesPageQuery() return { directoriesQuery: directories, title: m.settings(), } } ================================================ FILE: src/routes/(app)/(plain)/settings/components/DirectoriesList.svelte ================================================ {#snippet addButton(title: string, onclick: () => void)} {/snippet}
    {#each directories as dir}
  • {#if dir.legacy}
    {/if}
    {dir.legacy ? m.settingsTracksInsideAppMemory() : dir.handle.name}
    {m.settingsDirectoriesTracksCount({ count: dir.count })}
    {#if !(dir.legacy || isAndroidPlatform)} { window.goatcounter?.count({ path: 'action-rescan-directory', event: true }) void rescanDirectory(dir.id, dir.handle) }} /> {/if} { void removeDirectory(dir.id) }} />
  • {/each}
  • {#if isFileSystemAccessSupported} {@render addButton(m.settingsAddDirectory(), addNewDirectoryHandler)} {:else} {@render addButton(m.settingsImportTracks(), importLegacyFilesHandler)} {/if}
{#snippet directoryName(name: string | undefined)} {name} {/snippet} reparentDirectory, close: () => { reparentDirectory = null }, }} class="[--dialog-width:--spacing(85)]" icon="folderHidden" title={m.replaceDirectoryQ()} buttons={(data) => [ { title: m.cancel(), }, { title: m.replace(), action: () => { const ids = data.childDirs.map((dir) => dir.id) void replaceDirectories(data.newDirHandle, ids) }, }, ]} > {#snippet children({ data })} {#snippet existingDirs()} {#each data.childDirs as dir} {@render directoryName(dir.handle.name)} {/each} {/snippet} {#snippet newDir()} {@render directoryName(data.newDirHandle.name)} {/snippet} {/snippet} ================================================ FILE: src/routes/(app)/(plain)/settings/components/InstallAppBanner.svelte ================================================ {#if installEvent}
{m.settingsInstallAppExplanation({ device: isHandHeldDevice ? m.settingsInstallAppHomeScreen() : m.settingsInstallAppDesktop(), })}
{/if} ================================================ FILE: src/routes/(app)/(plain)/settings/components/MissingFsApiBanner.svelte ================================================
{m.settingsMissingFs1()} {m.settingsMissingFs2()} {m.settingsMissingFs3()} {m.settingsMissingFs4()} {m.settingsMissingFs5()}
================================================ FILE: src/routes/(app)/+layout.svelte ================================================ {#snippet directoriesPermissionSnackbar({ dirs, dismiss }: DirectoriesPermissionPromptSnackbarArg)}
{m.libraryDirPromptBrowserPermission()}
{m.libraryDirPromptExplanation()}
{#each dirs().slice(0, 3) as dir}
{dir.name}
{/each}
{/snippet} { if (e.key === ' ' && !isElementTextInput(e.target)) { e.preventDefault() player.togglePlay() } }} /> {#if navigating?.to}
{/if} {@render children()}
{#each overlaySnippets.abovePlayer as snippet} {@render snippet()} {/each} {#if !page.data.noPlayerOverlay} {/if}
{@render overlaySnippets.bottomBar?.()}
{#each APP_DIALOGS_KEYS as dialogKey} {@const DialogComponent = APP_DIALOGS_COMPONENTS_MAP[dialogKey]} {/each} ================================================ FILE: src/routes/(app)/layout/app-install-prompt.ts ================================================ export const setupAppInstallPromptListeners = () => { const main = useMainStore() window.addEventListener('appinstalled', () => { main.appInstallPromptEvent = null }) window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault() main.appInstallPromptEvent = e }) } ================================================ FILE: src/routes/(app)/layout/setup-directories-permission-prompt.svelte.ts ================================================ import { getDatabase } from '$lib/db/database' export interface DirectoryNeedingPermission { name: string action: () => Promise } export interface DirectoriesPermissionPromptSnackbarArg { dirs: () => DirectoryNeedingPermission[] dismiss: () => void } const dbGetDirectoriesNeedingPermission = async () => { const db = await getDatabase() const directories = await db.getAll('directories') const dirsWithPermissions = await Promise.all( directories.map(async (dir) => ({ ...dir, mode: await dir.handle.queryPermission({ mode: 'read' }), })), ) return dirsWithPermissions.filter((d) => d.mode === 'prompt') } export const setupDirectoriesPermissionPrompt = async ( snippet: Snippet<[DirectoriesPermissionPromptSnackbarArg]>, ): Promise => { const dirsNeedingPermissionItems = await dbGetDirectoriesNeedingPermission() if (dirsNeedingPermissionItems.length === 0) { return } const snackbarId = 'dirs-needing-permission' const dismiss = () => snackbar.dismiss(snackbarId) let dirsNeedingPermission = $state(dirsNeedingPermissionItems) const dirsItems = $derived( dirsNeedingPermission.map( (dir): DirectoryNeedingPermission => ({ name: dir.handle.name, action: async () => { const newMode = await dir.handle.requestPermission({ mode: 'read' }) if (newMode === 'granted') { dirsNeedingPermission = dirsNeedingPermission.filter((d) => d.id !== dir.id) } if (dirsNeedingPermission.length === 0) { dismiss() } }, }), ), ) const arg: DirectoriesPermissionPromptSnackbarArg = { dirs: () => dirsItems, dismiss, } snackbar({ id: snackbarId, message: '', duration: false, layout: 'column', controls: { type: 'snippet', arg, snippet, }, }) } ================================================ FILE: src/routes/(app)/layout/setup-theme.svelte.ts ================================================ import { isSafari } from '$lib/helpers/utils/ua' const updateThemeMetaElement = (element: Element) => { // Background color uses --surface color const surfaceColor = window.getComputedStyle(document.documentElement).backgroundColor element.setAttribute('content', surfaceColor) } const updateWindowTileBarColor = (isDark: boolean) => { // Safari does not respect media queries on theme meta element, // so instead we update all elements every time if (isSafari()) { const metaTags = document.querySelectorAll('meta[name="theme-color"]') for (const element of metaTags) { updateThemeMetaElement(element) } return } const element = document.querySelector( `meta[name="theme-color"][media="(prefers-color-scheme: ${isDark ? 'dark' : 'light'})"]`, ) if (element) { updateThemeMetaElement(element) } } export const setupTheme = (): void => { const player = usePlayer() const mainStore = useMainStore() $effect.pre(() => { document.documentElement.classList.toggle('dark', mainStore.isThemeDark) }) let initial = true $effect.pre(() => { const isDark = mainStore.isThemeDark const artworkArgb = mainStore.pickColorFromArtwork ? player.activeTrack?.primaryColor : undefined const argbOrHex = artworkArgb ?? mainStore.customThemePaletteHex if (initial) { initial = false if (isSafari()) { updateWindowTileBarColor(isDark) } // On initial load, if no custom color is set we can skip // loading module which relatively heavy if (!argbOrHex) { return } } void import('$lib/theme.ts').then(({ updateThemeCssVariables }) => { updateThemeCssVariables(argbOrHex, isDark) updateWindowTileBarColor(isDark) }) }) } ================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/+layout.svelte ================================================ {#snippet navItemsSnippet(className: string)} {#each navItems as item} {/each} {/snippet} {#snippet layoutBottom()} {#if isHandHeldDevice}
{@render navItemsSnippet('h-full')}
{/if} {/snippet} {#if layoutMode !== 'details'}
{@render navItemsSnippet('h-14 w-20')} {#if (slug === 'albums' || slug === 'artists') && isWideLayout} { main.librarySplitLayoutEnabled = !main.librarySplitLayoutEnabled }} /> {/if}
{/if} {#snippet list(mode)}
{#if slug === 'playlists'}
{/if} {#if data.tracksCountQuery.value === 0 && slug !== 'playlists'}
{m.libraryEmpty()}
{m.libraryStartByAdding()}
{:else}
{#if itemsIds.length === 0}
{m.libraryNoResults()}
{m.libraryNoResultsExplanation()}
{:else if slug === 'tracks'} {:else if slug === 'albums'} {:else if slug === 'artists'} {:else if slug === 'playlists'} playlist.id === FAVORITE_PLAYLIST_ID, items: (playlist) => getPlaylistMenuItems(dialogs, playlist), }} onItemClick={({ playlist }) => { const detailsViewId: RouteId = '/(app)/library/[[slug=libraryEntities]]/[uuid]' const shouldReplace = page.route.id === detailsViewId void goto(`/library/playlists/${playlist.uuid}`, { replaceState: shouldReplace }) }} /> {/if}
{/if}
{/snippet} {#snippet details()}
{#key page.url.pathname} {@render children()} {/key}
{/snippet}
================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/+layout.ts ================================================ import { redirect } from '@sveltejs/kit' import { innerWidth } from 'svelte/reactivity/window' import type { RouteId } from '$app/types' import type { LayoutMode } from '$lib/components/ListDetailsLayout.svelte' import { getLibraryItemIds } from '$lib/library/get/ids.ts' import { createLibraryItemKeysPageQuery, type PageQueryResult, } from '$lib/library/get/ids-queries.ts' import { createTracksCountPageQuery } from '$lib/library/tracks-queries.ts' import { FAVORITE_PLAYLIST_ID, type LibraryStoreName } from '$lib/library/types.ts' import { getPersistedLibrarySplitLayoutEnabled } from '$lib/stores/main/store.svelte.ts' import { defineViewTransitionMatcher } from '$lib/view-transitions.svelte.ts' import type { LayoutLoad } from './$types.ts' import { configsMap, type LibraryRouteConfig, type LibrarySearchFn } from './config.ts' import { LibraryStore } from './store.svelte.ts' const defaultSearchFn: LibrarySearchFn<{ name: string }> = (value, searchTerm) => value.name.toLowerCase().includes(searchTerm) type LoadDataResult = { [ExactSlug in Slug]: LibraryRouteConfig & { store: LibraryStore itemsIdsQuery: PageQueryResult tracksCountQuery: PageQueryResult } }[Slug] const loadData = async ( slug: Slug, ): Promise> => { const config = configsMap[slug] const searchFn = config.search ?? defaultSearchFn const store = new LibraryStore(slug) const itemsIdsQueryPromise = createLibraryItemKeysPageQuery(slug, { key: () => [slug, store.sortByKey, store.order, store.searchTerm.toLowerCase().trim()], fetcher: async ([name, sortKey, order, searchTerm], signal) => { const result = await getLibraryItemIds(name, { sort: sortKey, order, searchTerm, searchFn: (value) => searchFn(value, searchTerm), signal, }) if (slug === 'playlists') { return [FAVORITE_PLAYLIST_ID, ...result] } return result }, }) const [itemsIdsQuery, tracksCountQuery] = await Promise.all([ itemsIdsQueryPromise, createTracksCountPageQuery(), ]) return { ...config, store, itemsIdsQuery, tracksCountQuery, } } type LoadResult = LoadDataResult & { isWideLayout: () => boolean layoutMode: ( splitViewAllowed: boolean, isWide: boolean, itemId: string | undefined, ) => LayoutMode } export const load: LayoutLoad = async (event): Promise => { const { slug } = event.params if (!slug) { redirect(303, '/library/tracks') } const data = await loadData(slug) const isWideLayout = () => (innerWidth.current ?? 0) > 1154 // We pass params here so that inside page we can benefit from $derived caching const layoutMode = ( splitViewAllowed: boolean, isWide: boolean, itemUuid: string | undefined, ): LayoutMode => { if (slug === 'tracks') { return 'list' } if (isWide && splitViewAllowed) { return 'both' } if (itemUuid) { return 'details' } return 'list' } defineViewTransitionMatcher((to, from) => { const libraryRoute: RouteId = '/(app)/library/[[slug=libraryEntities]]' const detailsRoute: RouteId = `${libraryRoute}/[uuid]` if (to === libraryRoute && from === libraryRoute) { return { view: 'library' } } const mode = event.untrack(() => layoutMode(getPersistedLibrarySplitLayoutEnabled(), isWideLayout(), event.params.uuid), ) if (mode !== 'both') { return null } if ( (to === detailsRoute && from === libraryRoute) || (to === libraryRoute && from === detailsRoute) || (to === detailsRoute && from === detailsRoute) ) { return { view: 'library' } } return null }) return { ...data, isWideLayout, layoutMode, } } ================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/+page.svelte ================================================
{m.librarySelectSomethingToBeShown()}
================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/Search.svelte ================================================
searchHandler(e as unknown as InputEvent)} />
================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.svelte ================================================ {#if !(isWideLayout.current && main.librarySplitLayoutEnabled)}
{/if}
{#if slug !== 'playlists'} {/if}

{formatNameOrUnknown(item.name)}

{#if description}
{description}
{/if} {#if artists}
{artists}
{/if}
{#if slug === 'albums' && (item as AlbumData).year !== UNKNOWN_ITEM} {(item as AlbumData).year} • {/if} {m.libraryTracksCount({ count: tracks.tracksIds.length })}
{#if menuItems} menuItems} /> {/if}
================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.ts ================================================ import { error, redirect } from '@sveltejs/kit' import { goto } from '$app/navigation' import { type DbValue, getDatabase } from '$lib/db/database.ts' import { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts' import { dbGetAlbumTracksIdsByName } from '$lib/library/get/ids.ts' import { getLibraryValue } from '$lib/library/get/value.ts' import { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID, type LibraryStoreName, } from '$lib/library/types.ts' import type { PageLoad } from './$types.d.ts' type DetailsSlug = Exclude const createDetailsPageQuery = ( storeName: T, id: number, ): Promise>> => { const query = createPageQuery({ key: () => [storeName, id], fetcher: () => getLibraryValue(storeName, id), onDatabaseChange: (changes, actions) => { for (const change of changes) { if (change.storeName === storeName && change.key === id) { if (change.operation === 'delete') { void goto(`/library/${storeName}`, { replaceState: true }) return } // We always refetch because in most cases we would just hit Library Value Cache actions.refetch() break } } }, }) return query } export interface TracksQueryRegularResult { tracksIds: number[] playlistIdMap: null } const createTracksPageQuery = >( storeName: Slug, itemName: () => string, ): Promise> => { const query = createPageQuery({ key: () => [storeName, itemName()], fetcher: async ([, name]): Promise => { const db = await getDatabase() let keys: number[] if (storeName === 'albums') { keys = await dbGetAlbumTracksIdsByName(name) } else { keys = await db.getAllKeysFromIndex('tracks', 'artists', IDBKeyRange.only(name)) } return { tracksIds: keys, playlistIdMap: null } }, onDatabaseChange: (changes, actions) => { for (const change of changes) { if (change.storeName === 'tracks') { // We can't know the order actions.refetch() break } } }, }) return query } export interface PlaylistTrackItem { trackId: number uuid: string } export interface PlaylistTracksQueryResult { tracksIds: number[] playlistIdMap: Record } const createPlaylistTracksPageQuery = ( playlistId: number, ): Promise> => { const query = createPageQuery({ key: () => [playlistId], fetcher: async (): Promise => { const db = await getDatabase() const values = await db.getAllFromIndex('playlistEntries', 'playlistId', playlistId) const tracksIds: number[] = Array.from({ length: values.length }) const playlistIdMap: Record = {} for (let i = 0; i < values.length; i += 1) { // biome-ignore lint/style/noNonNullAssertion: value is always defined const value = values[i]! tracksIds[i] = value.trackId playlistIdMap[value.trackId] = value.id } return { tracksIds, playlistIdMap } }, onDatabaseChange: (changes, actions) => { for (const change of changes) { if ( change.storeName === 'playlistEntries' && change.value.playlistId === playlistId ) { // We can't know the order actions.refetch() break } } }, }) return query } interface LoadResult { slug: DetailsSlug itemQuery: PageQueryResult> tracksQuery: PageQueryResult } export const load: PageLoad = async (event): Promise => { const { slug } = event.params if (!slug || slug === 'tracks') { redirect(303, '/library/tracks') } const uuid = event.params.uuid if (!uuid) { error(404) } let id: number | undefined if (uuid === FAVORITE_PLAYLIST_UUID) { id = FAVORITE_PLAYLIST_ID } else { const db = await getDatabase() id = await db.getKeyFromIndex(slug, 'uuid', uuid) } if (!id) { error(404) } const itemQuery = await createDetailsPageQuery(slug, id) const tracksQuery = await (slug === 'playlists' ? createPlaylistTracksPageQuery(id) : createTracksPageQuery(slug, () => itemQuery.value.name)) return { slug, itemQuery, tracksQuery, } } ================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/config.ts ================================================ import type { DbValue } from '$lib/db/database.ts' import type { LibraryItemSortKey } from '$lib/library/get/ids.ts' import type { LibraryStoreName } from '$lib/library/types' export type LibrarySearchFn = (value: Value, searchTerm: string) => boolean export interface SortOption { name: string key: LibraryItemSortKey } export interface LibraryRouteConfig { slug: Slug singularTitle: () => string pluralTitle: () => string search?: LibrarySearchFn> sortOptions: () => SortOption[] } const includesTerm = (target: string | undefined | null, term: string) => target?.toLowerCase().includes(term) const artistsIncludesTerm = (item: { artists: string[] | undefined }, term: string) => item.artists?.some((artist) => includesTerm(artist, term)) ?? false const nameSortOption = { name: m.name(), key: 'name', } as const const trackConfig: LibraryRouteConfig<'tracks'> = { slug: 'tracks', singularTitle: m.track, pluralTitle: m.tracks, search: (value, searchTerm) => { if (includesTerm(value.name, searchTerm)) { return true } if (includesTerm(value.album, searchTerm)) { return true } if (artistsIncludesTerm(value, searchTerm)) { return true } return false }, sortOptions: () => [ nameSortOption, { name: m.artist(), key: 'artists', }, { name: m.album(), key: 'byAlbumSorted', }, { name: m.duration(), key: 'duration', }, { name: m.year(), key: 'year', }, ], } const albumConfig: LibraryRouteConfig<'albums'> = { slug: 'albums', singularTitle: m.album, pluralTitle: m.albums, search: (value, searchTerm) => { if (includesTerm(value.name, searchTerm)) { return true } if (artistsIncludesTerm(value, searchTerm)) { return true } return false }, sortOptions: () => [nameSortOption], } const artistConfig: LibraryRouteConfig<'artists'> = { slug: 'artists', singularTitle: m.artist, pluralTitle: m.artists, sortOptions: () => [nameSortOption], } const playlistConfig: LibraryRouteConfig<'playlists'> = { slug: 'playlists', singularTitle: m.playlist, pluralTitle: m.playlists, sortOptions: () => [nameSortOption, { name: m.created(), key: 'createdAt' }], } type LibraryRouteConfigsMap = { [Slug in LibraryStoreName]: LibraryRouteConfig } export const configsMap: LibraryRouteConfigsMap = { tracks: trackConfig, albums: albumConfig, artists: artistConfig, playlists: playlistConfig, } ================================================ FILE: src/routes/(app)/library/[[slug=libraryEntities]]/store.svelte.ts ================================================ import { persist } from '$lib/helpers/persist.svelte.ts' import type { LibraryItemSortKey, SortOrder } from '$lib/library/get/ids.ts' import type { LibraryStoreName } from '$lib/library/types.ts' export class LibraryStore { searchTerm: string = $state('') order: SortOrder = $state('asc') sortByKey: LibraryItemSortKey = $state('name') constructor(slug: Slug) { persist(`library:${slug}`, this, ['sortByKey', 'order']) // Previous version used 'album' key for sorting track by album. // Update value after loading persisted state. if (slug === 'tracks' && this.sortByKey === 'album') { this.sortByKey = 'byAlbumSorted' as LibraryItemSortKey } } } ================================================ FILE: src/routes/(app)/player/+layout.svelte ================================================ {#snippet playerSnippet()}
{m.player()}
{#if mainStore.volumeSliderEnabled}
(player.volume -= 10)} /> (player.volume += 10)} />
{/if}
{#if activeTrack}
{player.activeTrackIndex + 1}
{activeTrack.name}
{formatArtists(activeTrack.artists)}
{/if}
{ dialogs.openDialog('equalizer') }} > {#if layoutMode === 'list'} {/if}
{/snippet} {#snippet emptyList(title: string)}
{title}
{/snippet} {#snippet queueSnippet()}
[ 'border-b', isElevated ? 'border-transparent' : 'border-onSecondaryContainer/24', ]} >
{ void goto(`/player/${item.id}`, { replaceState: true }) }} > {#snippet text(item)} {item.text} {/snippet}
{#if isSelectedTabQueue} {:else} void clearPlayHistory()} /> {/if}
{#if isSelectedTabQueue} {#if player.isQueueEmpty} {@render emptyList(m.playerQueueEmpty())} {:else} { player.moveQueueItem(fromIndex, toIndex) }} predefinedMenuItems={{ disableAddToQueue: true, }} menuItems={(_track, index) => [ { label: m.playerRemoveFromQueue(), action: () => { player.removeFromQueue(index) }, }, ]} onItemClick={({ index }) => { player.playTrack(index) }} /> {/if} {:else if data.historyTrackIds.value.length === 0} {@render emptyList(m.playerHistoryEmpty())} {:else} [ { label: m.playerRemoveFromHistory(), action: () => { void dbRemoveFromPlayHistory(item.id) }, }, ]} onItemClick={({ track }) => { const trackIndexInQueue = player.itemsIds.indexOf(track.id) if (trackIndexInQueue !== -1) { player.playTrack(trackIndexInQueue) return } player.playTrack(0, [track.id]) }} /> {/if}
{/snippet} ================================================ FILE: src/routes/(app)/player/+layout.ts ================================================ import { getDatabase } from '$lib/db/database.ts' import { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts' import { defineViewTransitionMatcher } from '$lib/view-transitions.svelte.ts' import type { LayoutLoad } from './$types.ts' import { getLayoutProps } from './layout-props.ts' interface LoadResult { historyTrackIds: PageQueryResult noPlayerOverlay: boolean htmlOverflow: 'auto' } const createPlayHistoryQuery = () => createPageQuery({ key: [], fetcher: async () => { const db = await getDatabase() const entries = await db.getAllFromIndex('playHistory', 'playedAt') return entries.map((entry) => entry.trackId).reverse() }, onDatabaseChange: (changes, actions) => { for (const change of changes) { if (change.storeName === 'playHistory') { void actions.refetch() return } } }, }) export const load: LayoutLoad = async (): Promise => { defineViewTransitionMatcher((to, from) => { const playerRouteId = '/(app)/player' const prevRouteWasPlayer = from.startsWith(playerRouteId) const nextRouteIsPlayer = to.startsWith(playerRouteId) if (prevRouteWasPlayer && nextRouteIsPlayer) { const { layoutMode } = getLayoutProps(to) if (layoutMode === 'both' || (layoutMode === 'details' && from !== playerRouteId)) { return { view: 'disabled' } } // Use default transition return null } if (prevRouteWasPlayer) { return { view: 'player', backwards: true } } if (nextRouteIsPlayer) { return { view: 'player' } } return null }) const historyTrackIds = await createPlayHistoryQuery() return { historyTrackIds, noPlayerOverlay: true, htmlOverflow: 'auto', } } ================================================ FILE: src/routes/(app)/player/+page.ts ================================================ import type { PageLoad } from './$types.ts' export const load: PageLoad = (): void => {} ================================================ FILE: src/routes/(app)/player/history/+page.ts ================================================ import type { PageLoad } from './$types.ts' export const load: PageLoad = (): void => {} ================================================ FILE: src/routes/(app)/player/layout-props.ts ================================================ import { innerHeight, innerWidth } from 'svelte/reactivity/window' import type { RouteId } from '$app/types' import type { LayoutMode } from '$lib/components/ListDetailsLayout.svelte' const isRouteQueueOrHistory = (routeId: RouteId): boolean => routeId === '/(app)/player/queue' || routeId === '/(app)/player/history' const getLayoutMode = (isCompact: boolean, routeId: RouteId | null): LayoutMode => { if (!isCompact) { return 'both' } if (routeId && isRouteQueueOrHistory(routeId)) { return 'details' } return 'list' } export interface LayoutProps { isCompactVertical: boolean isCompactHorizontal: boolean isCompact: boolean layoutMode: LayoutMode } export const getLayoutProps = (routeId: RouteId | null): LayoutProps => { const isCompactVertical = (innerHeight.current ?? 0) < 600 const isCompactHorizontal = (innerWidth.current ?? 0) < 768 const isCompact = isCompactVertical || isCompactHorizontal return { isCompactVertical, isCompactHorizontal, isCompact, layoutMode: getLayoutMode(isCompact, routeId), } } ================================================ FILE: src/routes/(app)/player/queue/+page.ts ================================================ import type { PageLoad } from './$types.ts' export const load: PageLoad = (): void => {} ================================================ FILE: src/routes/(assets)/icons/icon.server.ts ================================================ import { THEME_PALLETTE_LIGHT } from '../../../server/theme-colors.ts' const foregroundColor = THEME_PALLETTE_LIGHT.onPrimary export const getAppIcon = (clipBounds = false): string => ` ${ clipBounds ? ` ` : '' } ` ================================================ FILE: src/routes/(assets)/manifest.webmanifest/+server.ts ================================================ import { APP_DESCRIPTION_EN, APP_NAME_EN, APP_NAME_SHORT_EN } from '$lib/app-metadata.ts' import { THEME_PALLETTE_DARK } from '../../../server/theme-colors.ts' export const prerender = true const manifest = { short_name: APP_NAME_SHORT_EN, name: APP_NAME_EN, start_url: './library/tracks/', scope: '../', theme_color: THEME_PALLETTE_DARK.surface, background_color: THEME_PALLETTE_DARK.surface, display: 'standalone', orientation: 'any', description: APP_DESCRIPTION_EN, icons: [ { src: '/icons/raster-192.png', sizes: '192x192', type: 'image/png', purpose: 'any', }, { src: '/icons/responsive.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any', }, { src: '/icons/maskable.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'maskable', }, ], } export const GET = () => new Response(JSON.stringify(manifest), { headers: { 'Content-Type': 'application/manifest+json', }, }) ================================================ FILE: src/routes/(marketing)/+page.svelte ================================================ {seoTitle} {@html ``}
{m.appName()}
trackOpenPlayerClick('hero')} /> trackOpenPlayerClick('getting-started')} />
================================================ FILE: src/routes/(marketing)/+page.ts ================================================ import '../../app.css' export const ssr = true export const prerender = true export const csr = true ================================================ FILE: src/routes/(marketing)/AGENTS.md ================================================ # Marketing Route Guidance When editing the landing page under `src/routes/(marketing)/`, follow `TONE_OF_VOICE.md`. Treat it as the source of truth for: - wording and section tone - how concrete or technical copy should be - what kinds of marketing phrases to avoid - how to keep sections distinct and non-duplicative Default behavior: - prefer the clearer version over the cleverer version - match product terminology exactly when possible - cut lines that sound generic, cliche, or duplicative ================================================ FILE: src/routes/(marketing)/TONE_OF_VOICE.md ================================================ # Marketing Tone Of Voice Use this guide for all copy on the landing page and other marketing-facing surfaces for Snae Player. ## Voice In One Paragraph Write like you are explaining a real product to a skeptical user. The tone should be clear, concrete, calm, and grounded in actual behavior. It should feel trustworthy and modern, but it does not need to sound stiff. A small amount of warmth or playful phrasing is good when it still points to something real in the product. Avoid hype, empty cleverness, and copy generic enough to fit any music app. The goal is not "serious at all times". The goal is "friendly, believable, and true." ## Product Positioning Keep new copy anchored in these points: - local music player - in the browser - private by default - usable without sign-up or uploads - built around real listening features like playlists, queue, favorites, equalizer, and playback speed If a sentence does not clearly connect back to one of those ideas, it is probably weak or unnecessary. The connection can be explicit or implied, but it should still be recognizable. ## Tone By Layer Not every part of the page should sound equally playful. Use more warmth in: - headlines - section labels - short framing lines - feature names when they still map cleanly to real behavior - feature descriptions when they describe a real listening benefit in plain language Stay more literal in: - hero supporting copy - privacy and permissions language - onboarding and setup steps - browser support notes - any sentence that sounds like a promise or guarantee Simple rule: the closer a line is to a claim, instruction, or guarantee, the more exact it should be. Another simple rule: do not flatten every line into product-spec language. Benefit-led phrasing is fine when the feature behind it is obvious and defensible. ## Non-Negotiable Rules Use this order when making copy decisions: 1. Write about what the app actually does. 2. Use the labels users actually see in the app whenever possible. 3. Make every meaningful claim easy to defend from the product. Prefer: - "Open a music folder or pick individual tracks" - "Add a directory" - "Tracks, albums, and artists are organized automatically" - "No sign-up, no cloud sync, and no download required" - "Built around your music" - "Colors that follow your music" - "Adjust the sound, set your pace" - "Listen at any speed" - "Build playlists for any mood" Avoid: - "Seamless audio experiences" - "Powerful music management" - "Next-generation playback" - "Built for people who love music" If a sentence could apply to almost any music app, it is probably too vague. That does not mean every sentence needs to sound mechanical. A line can be broader or warmer if it still clearly belongs to this product and points to a real feature. ### Use Real Labels Use the labels users actually see in the app whenever possible. - Use "Add directory" because that is the real action in Settings. - Use "Open Player" if that is the actual CTA. - Refer to equalizer, playback speed, playlists, queue, and favorites exactly as they exist in product UI. Do not invent nicer-sounding labels if they create distance from the real product. ### Defend Every Claim Every meaningful claim should be easy to defend from the product. This includes implication, not just literal wording. A line should not suggest a capability, convenience, or privacy guarantee that the product does not clearly provide. Good: - "Your files stay on your device" - "Works offline" - "The browser asks before giving access" Weak or risky: - "Complete control over your music" - "The best way to manage local audio" - "Ultimate privacy" If a claim feels broad, narrow it until it is plainly true. When in doubt, ask: what would a careful user think this line promises? If that promise is even slightly stronger than the real product behavior, rewrite it. ### Explain Privacy Calmly Privacy matters, but the tone should stay matter-of-fact. Prefer: - "No sign-up, no cloud sync" - "The browser asks before giving access" - "Library data stays on this device" Avoid: - fear-based language - dramatic warnings - sounding anti-technology or anti-cloud in general The message is that the app is private because of how it works, not because it is making ideological speeches. Do not lean on empty emotional framing. Avoid lines like: - "Made for people who love music" - "Built for audiophiles and casual listeners alike" - "Bring your music to life" These add tone without adding information. Use this distinction: - "Built around your music" is acceptable because it frames a real product direction. - "Bring your music to life" is weak because it adds mood without saying anything concrete. ### Do Not Drift Into Internals The page should explain enough to build trust, but should not drift into implementation detail. Good: - "Pick a folder, approve access in the browser" - "Works in all modern browsers" Avoid unless truly needed: - storage engine names - browser API names in body copy - internal implementation detail like how files are copied or persisted Technical detail is acceptable only when it directly helps trust or clarity. ### Keep Sentences Short And Useful Landing-page copy should scan quickly. - Lead with the main point. - Keep descriptions compact. - Remove filler transitions. - Cut any sentence that repeats the section title without adding information. ### Give Each Section One Job - Do not restate the same benefit in multiple sections. - If a section repeats another section with softer wording, cut or rewrite it. ## Preferred Patterns ### Headlines - Keep them short. - Make them product-first. - Mild slogan energy is fine if the meaning stays clear. - Avoid metaphor or brand-language that hides what the product actually does. ### Descriptions - Keep them to one concrete action or benefit. - Prefer one strong sentence over two softer ones. - Explain what the user can do or what the app guarantees. - If a heading is slightly playful, let the description do the grounding. - Benefit-first phrasing is acceptable when the related feature is named nearby and the implication stays modest. ### CTA Copy - Match real UI labels. - Prefer direct verbs. - Do not invent more polished alternatives if the in-product label is already clear. - If the landing page CTA leads to setup rather than immediate use, the wording should not imply instant playback. ### Trust Copy - Explain permission, privacy, and browser behavior plainly. - Describe behavior, not philosophy. ## Project Preferences - Prefer direct phrasing over polished marketing phrasing. - Prefer "concrete and trustworthy" over "clever and branded". - Allow a small amount of playful or warm wording in headings and section framing. - Do not overcorrect into overly strict or sterile copy. - Do not rewrite solid existing copy just to make it sound more literal if it is already truthful, specific enough, and product-shaped. - Avoid cliche lines immediately if they feel generic. - Use fewer section descriptions when the section title and content already carry the idea. - Use browser-support language in a practical way, not as a technical brag. - If a phrase sounds like it could belong on any startup landing page, rewrite it. ## Example Rewrites Stronger: - "Open a music folder or pick individual tracks, then start listening right away." - "The browser asks before giving access." - "No sign-up, no cloud sync, and no download required." - "Play music from your device." - "Built around your music." - "Colors that follow your music." - "Up and running in seconds." - "A clean player for your local music, with playlists, sound controls, and offline listening." - "Build playlists for any mood, star what you love, and line up what plays next." - "Adjust the sound, set your pace." - "Listen at any speed." Weaker: - "Enjoy seamless playback from your personal collection." - "Security comes first in our architecture." - "A delightful listening experience tailored to you." - "Built for people who love music." - "Experience music like never before." ## Quick Copy Checklist Before finalizing new marketing copy, check: - Is this wording specific to Snae Player? - Does it match real product behavior and labels? - Is it truthful in implication, not just literally defensible? - Is it saying something new, or repeating another section? - Can the sentence be shortened without losing meaning? - Does it sound calm and credible? - Would a skeptical user believe it immediately? ## Default Rule When choosing between: - more clever vs more clear - more branded vs more specific - more polished vs more believable choose the clearer, more specific, more believable version. If both versions are equally true and clear, it is okay to choose the one with a little more warmth. ================================================ FILE: src/routes/(marketing)/components/FeaturesSection.svelte ================================================
{#each features as feature}

{feature.title}

{feature.description}

{/each}
================================================ FILE: src/routes/(marketing)/components/GettingStartedSection.svelte ================================================
{#each steps as step, i}
{i + 1}

{step.title}

{step.description}

{/each}

Works in modern browsers on Android and iOS, plus Chromebooks, Windows PCs, and Macs.

================================================ FILE: src/routes/(marketing)/components/HeroSection.svelte ================================================

Your local music player, in the browser.

Playlists, queue, equalizer, and playback speed — no uploads or account needed.

Free • Works offline • Open source
Snae Player showing the music library and playback controls
Private by default
Your files stay on your device
================================================ FILE: src/routes/(marketing)/components/HowItWorksSection.svelte ================================================
{#each detailCards as card}

{card.title}

{card.description}

{/each}
================================================ FILE: src/routes/(marketing)/components/Section.svelte ================================================
{label}

{title}

{#if description}

{description}

{/if}
{@render children?.()}
================================================ FILE: src/routes/(marketing)/components/SoundControlsSection.svelte ================================================
Built-in equalizer

Tune the equalizer for your headphones or speakers

Start with a preset, then fine-tune each band until the sound is right.

Playback speed

Listen at any speed

Slow down for closer listening or move through albums and long mixes at your own pace.

Snae Player equalizer dialog in dark mode with Bass Boost enabled
================================================ FILE: src/routes/+error.svelte ================================================

{#if is404} 404 {:else} {page.error?.message} {/if}

{#if is404}
{m.errorPageDoesNotExist()}
{/if}
================================================ FILE: src/routes/+layout.svelte ================================================ {@render children()} ================================================ FILE: src/routes/+layout.ts ================================================ import '../app.css' import { browser } from '$app/environment' import { registerServiceWorker } from '$lib/helpers/register-sw' import { baseLocale, isLocale, overwriteGetLocale, overwriteSetLocale } from '$paraglide/runtime' export const ssr = false export const prerender = false const initLocale = () => { const savedLocale = localStorage.getItem('snae-locale') const locale = isLocale(savedLocale) ? savedLocale : baseLocale document.documentElement.lang = locale return locale } if (browser) { const locale = initLocale() overwriteGetLocale(() => locale) overwriteSetLocale((newLocale) => { localStorage.setItem('snae-locale', newLocale) window.location.reload() }) registerServiceWorker({ onNeedRefresh(update) { snackbar({ id: 'app-update', message: m.appUpdateAvailable(), duration: false, controls: { label: m.reload(), action: update, }, }) }, }) } ================================================ FILE: src/server/theme-colors.ts ================================================ import type { PaletteToken } from '$lib/theme.ts' import themeCss from '../theme-colors.css?raw' /** @public */ export const THEME_PALLETTE_LIGHT = {} as Record /** @public */ export const THEME_PALLETTE_DARK = {} as Record const regex = /--color-([a-zA-Z0-9-]+):\s*light-dark\(\s*(#[a-fA-F0-9]{3,6})\s*,\s*(#[a-fA-F0-9]{3,6})\s*\)/g for (const match of themeCss.matchAll(regex)) { const colorName = match[1] as PaletteToken const lightColor = match[2] const darkColor = match[3] invariant(lightColor && darkColor, `Invalid color definition for ${colorName}`) THEME_PALLETTE_LIGHT[colorName] = lightColor THEME_PALLETTE_DARK[colorName] = darkColor } ================================================ FILE: src/service-worker.ts ================================================ /// /// /// import { PUBLIC_FALLBACK_PAGE } from '$env/static/public' import { build, files, prerendered, version } from '$service-worker' declare const self: ServiceWorkerGlobalScope const CACHE = `cache-${version}` const ASSETS = [...build, ...files, ...prerendered, PUBLIC_FALLBACK_PAGE] self.addEventListener('install', (event) => { // Create a new cache and add all files to it async function addFilesToCache() { const cache = await caches.open(CACHE) await cache.addAll(ASSETS) } event.waitUntil(addFilesToCache()) }) self.addEventListener('activate', (event) => { self.clients.claim() // Remove previous cached data from disk async function deleteOldCaches() { for (const key of await caches.keys()) { if (key !== CACHE) { await caches.delete(key) } } } event.waitUntil(deleteOldCaches()) }) self.addEventListener('fetch', (event) => { const { request } = event // ignore POST requests etc if (request.method !== 'GET') { return } const url = new URL(request.url) if (url.origin !== self.location.origin) { return } const isNavigationRequest = request.mode === 'navigate' // biome-ignore lint/complexity/useSimplifiedLogicExpression: for clarity if (!ASSETS.includes(url.pathname) && !isNavigationRequest) { return } async function respond() { const cache = await caches.open(CACHE) let response = await cache.match(url.pathname) if (response) { return response } try { response = await fetch(request) // if we're offline, fetch can return a value that is not a Response // instead of throwing - and we can't pass this non-Response to respondWith if (!(response instanceof Response)) { throw new Error('invalid response from fetch') } return response } catch (error) { if (isNavigationRequest) { const fallbackResponse = await cache.match(PUBLIC_FALLBACK_PAGE) if (fallbackResponse) { console.info('Serving fallback page') return fallbackResponse } } throw error } } event.respondWith(respond()) }) self.addEventListener('message', (event) => { if (event.data === 'skip-waiting') { self.skipWaiting() } }) ================================================ FILE: src/theme-colors.css ================================================ /* This file is auto generated, do not edit manually. */ @theme { --color-*: initial; --color-transparent: transparent; --color-current: currentColor; --color-primary: light-dark(#7c5800, #f7bd48); --color-onPrimary: light-dark(#ffffff, #412d00); --color-primaryContainer: light-dark(#ffdea7, #5e4200); --color-onPrimaryContainer: light-dark(#271900, #ffdea7); --color-secondary: light-dark(#6d5c3f, #dac4a0); --color-onSecondary: light-dark(#ffffff, #3c2e15); --color-secondaryContainer: light-dark(#f7dfbb, #54452a); --color-secondaryContainerVariant: light-dark(#cbb694, #30240c); --color-onSecondaryContainer: light-dark(#251a04, #f7dfbb); --color-tertiary: light-dark(#4c6544, #b3cea6); --color-onTertiary: light-dark(#ffffff, #1f361a); --color-tertiaryContainer: light-dark(#ceebc1, #354d2e); --color-onTertiaryContainer: light-dark(#0b2007, #ceebc1); --color-error: light-dark(#ba1a1a, #ffb4ab); --color-onError: light-dark(#ffffff, #690005); --color-errorContainer: light-dark(#ffdad6, #93000a); --color-onErrorContainer: light-dark(#410002, #ffdad6); --color-surface: light-dark(#fff8f3, #201b13); --color-onSurface: light-dark(#201b13, #ece1d4); --color-surfaceVariant: light-dark(#eee1cf, #4e4639); --color-onSurfaceVariant: light-dark(#4e4639, #d1c5b4); --color-surfaceContainerHighest: light-dark(#ece1d4, #3a342b); --color-surfaceContainerHigh: light-dark(#f1e7d9, #2f2921); --color-surfaceContainer: light-dark(#f7ecdf, #241f17); --color-surfaceContainerLow: light-dark(#fdf2e5, #201b13); --color-surfaceContainerLowest: light-dark(#ffffff, #120e07); --color-surfaceBright: light-dark(#fff8f3, #3e382f); --color-surfaceDim: light-dark(#e3d8cc, #17130b); --color-outline: light-dark(#807667, #9a8f80); --color-outlineVariant: light-dark(#d1c5b4, #4e4639); --color-shadow: light-dark(#000000, #000000); --color-scrim: light-dark(#000000, #000000); --color-inverseSurface: light-dark(#353027, #ece1d4); --color-inverseOnSurface: light-dark(#faefe2, #201b13); --color-inversePrimary: light-dark(#f7bd48, #7c5800); } ================================================ FILE: static/supported-browser-check.js ================================================ // IMPORTANT. This file must be imported as separate entry point // and it cannot use any modern JS syntax. var isSupportedBrowser = 'noModule' in HTMLScriptElement.prototype && navigator.locks && 'timeout' in AbortSignal && CSS && CSS.supports('color: color-mix(in oklab, black, black)') && // Container queries 'container' in document.documentElement.style && navigator.serviceWorker if (!isSupportedBrowser) { document.getElementById('unsupported-browser').removeAttribute('hidden') document.getElementById('app').style.display = 'none' } ================================================ FILE: svelte.config.js ================================================ /** @import { Config } from '@sveltejs/kit' */ import adapter from '@sveltejs/adapter-static' import { loadEnv } from 'vite' const env = loadEnv('production', process.cwd(), 'PUBLIC_') /** @type {Config} */ const config = { compilerOptions: { runes: true, experimental: { async: true, }, }, kit: { paths: { relative: false, }, outDir: './.generated/svelte-kit', adapter: adapter({ // When changing this, also update env variable fallback: '200.html', }), alias: { $paraglide: './.generated/paraglide', }, csp: { directives: { 'default-src': ['none'], 'script-src': ['self', 'https://gc.zgo.at/'], 'style-src': ['self', 'unsafe-inline'], 'img-src': [ 'self', 'blob:', env.PUBLIC_GOAT_COUNTER_URL ? `${env.PUBLIC_GOAT_COUNTER_URL}/count` : '', ], 'media-src': ['self', 'blob:'], 'font-src': ['self'], 'connect-src': ['self', env.PUBLIC_GOAT_COUNTER_URL ?? ''], 'form-action': ['none'], 'manifest-src': ['self'], 'base-uri': ['none'], }, }, typescript: { config: (tsConfig) => { tsConfig.extends = '../../tsconfig.base.json' tsConfig.include.push('../paraglide/**/*') return tsConfig }, }, serviceWorker: { register: false, }, }, } export default config ================================================ FILE: tsconfig.base.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "allowJs": true, "moduleDetection": "force", "allowImportingTsExtensions": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, "noUnusedLocals": true, "noUnusedParameters": false, "noUncheckedIndexedAccess": true, "allowUnreachableCode": false, "allowUnusedLabels": false, "declaration": true, "libReplacement": false, "erasableSyntaxOnly": true, "composite": true, "emitDeclarationOnly": true, "verbatimModuleSyntax": true, "isolatedModules": true, "lib": ["esnext", "DOM", "DOM.Iterable"], "moduleResolution": "bundler", "module": "esnext" }, "include": [], "exclude": [] } ================================================ FILE: tsconfig.json ================================================ { "extends": "./.generated/svelte-kit/tsconfig.json", "compilerOptions": { "types": ["@types/wicg-file-system-access", "./.generated/types/auto-imports.d.ts"] }, "references": [ { "path": "./tsconfig.node.json" } ] } ================================================ FILE: tsconfig.node.json ================================================ { "extends": "./tsconfig.base.json", "compilerOptions": { "types": ["@types/node"] }, "files": ["vite.config.ts", "svelte.config.js", "src/lib/theme.ts"], "include": ["lib/**/*", "scripts/**/*"] } ================================================ FILE: vite.config.ts ================================================ import { paraglideVitePlugin } from '@inlang/paraglide-js' import { sveltekit } from '@sveltejs/kit/vite' import tailwindcss from '@tailwindcss/vite' import AutoImport from 'unplugin-auto-import/vite' import { defineConfig } from 'vite' import { imageMetadataPlugin } from './lib/vite-image-metadata.ts' import { logChunkSizePlugin } from './lib/vite-log-chunk-size.ts' const getAutoImportPlugin = (dts: string | false = false) => AutoImport({ dts, imports: [ { '$paraglide/messages': [['*', 'm']], '$lib/stores/player/use-store.ts': ['usePlayer'], '$lib/stores/main/use-store.ts': ['useMainStore'], '$lib/stores/dialogs/use-store.ts': ['useDialogsStore'], '$lib/components/menu/MenuRenderer.svelte': ['useMenu'], '$lib/components/snackbar/snackbar.ts': ['snackbar'], 'tiny-invariant': [['default', 'invariant']], svelte: ['untrack'], }, ], }) export default defineConfig({ server: { fs: { allow: ['./.generated'], }, warmup: { // Avoids page reloading in Dev mode. When vite supports bundled-dev mode this can be removed. clientFiles: [ 'src/lib/components/**/*.svelte', 'src/lib/library/scan-actions/scanner/worker.ts', ], }, }, // Tell Vitest to use the `browser` entry points in `package.json` files, even though it's running in Node resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined, build: { target: ['chrome130', 'safari18'], rolldownOptions: { output: { comments: false, advancedChunks: { groups: [ { // Merge all css into a single file name: 'styles', test: /\.css$/, minModuleSize: 0, priority: 100, }, { // Merge smaller chunks than together name: 'small-chunks', maxModuleSize: 1 * 1024, }, ], }, }, }, }, worker: { format: 'es', plugins: () => [getAutoImportPlugin()], }, plugins: [ imageMetadataPlugin(), tailwindcss(), sveltekit(), paraglideVitePlugin({ project: './project.inlang', outdir: './.generated/paraglide', strategy: ['baseLocale'], isServer: 'import.meta.env.SSR', }), getAutoImportPlugin('./.generated/types/auto-imports.d.ts'), logChunkSizePlugin(), { name: 'ssr-config', config(config) { const isSsr = config?.build?.ssr // Since this is mostly SPA, server logs are mostly noise. config.logLevel = isSsr ? 'warn' : 'info' return config }, }, ], }) ================================================ FILE: vitest.config.ts ================================================ import { defaultExclude, defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config.ts' export default mergeConfig( viteConfig, defineConfig({ test: { environment: 'happy-dom', exclude: [...defaultExclude, '.generated/**', 'build/**'], }, }), )