[
  {
    "path": ".env.example",
    "content": "PUBLIC_FALLBACK_PAGE=/200.html\nPUBLIC_GOAT_COUNTER_URL="
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n---\n\n## ⚠️ Before creating this issue\n\n**Please check if a similar issue already exists:**\n- [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this bug has not been reported before\n\n## 🐛 Bug Description\n\nA clear and concise description of what the bug is.\n\n## 🔄 Steps to Reproduce\n\n1. Go to '...'\n2. Click on '...'\n3. Scroll down to '...'\n4. See error\n\n## ✅ Expected Behavior\n\nA clear and concise description of what you expected to happen.\n\n## ❌ Actual Behavior\n\nA clear and concise description of what actually happened.\n\n## 📱 Device Information\n\n**Device Type:** (e.g., Desktop, Mobile, Tablet)\n**Operating System:** (e.g., Windows 11, macOS 14, iOS 17, Android 13)\n**Browser:** (e.g., Chrome 120, Firefox 121, Safari 17)\n**Browser Version:** \n**Screen Resolution:** (if relevant)\n\n## 📸 Screenshots/Videos\n\nIf applicable, add screenshots or videos to help explain your problem.\n\n<!-- You can drag and drop images/videos directly into this text area -->\n\n## 🎵 Music Library Details (if relevant)\n\n**Library Size:** (approximate number of songs/albums)\n**File Formats:** (e.g., MP3, FLAC, AAC)\n\n## 🔧 Additional Context\n\nAdd any other context about the problem here. Include:\n- Console errors (if any)\n- Network connectivity issues\n- Any recent changes to your music library\n- Whether this happens consistently or intermittently\n\n## 📋 Browser Console Errors\n\nIf there are any console errors, please include them here:\n\n```\nPaste console errors here\n```\n\n## 🔍 Additional Information\n\n- Does this issue occur in incognito/private browsing mode?\n- Have you tried clearing browser cache/data?\n- Does this issue occur on other devices/browsers?\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: ❓ Questions & Support\n    url: https://github.com/minht11/local-music-pwa/discussions\n    about: Ask questions, get help, or discuss how to use the app\n  - name: 💬 General Discussions\n    url: https://github.com/minht11/local-music-pwa/discussions\n    about: Share ideas, feedback, or chat with the community\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n---\n\n## ⚠️ Before creating this issue\n\n**Please check if a similar feature has already been requested:**\n- [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this feature has not been requested before\n\n## 💡 What feature would you like to see?\n\nA clear description of the feature you'd like to see implemented.\n\n## 🎯 Why do you need this feature?\n\nWhat problem would this solve or what would this help you do?\n\n## 🎨 Screenshots/Examples (Optional)\n\nIf you have examples from other apps or mockups, add them here.\n\n<!-- You can drag and drop images directly into this text area -->\n\n## 📋 Additional Context\n\nAny other details that might be helpful.\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n  push:\n    branches:\n      - main\n\nenv:\n  PUBLIC_FALLBACK_PAGE: ${{ vars.PUBLIC_FALLBACK_PAGE }}\n  PUBLIC_GOAT_COUNTER_URL: ${{ vars.PUBLIC_GOAT_COUNTER_URL }}\n\njobs:\n  code-quality:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6.0.2\n      - uses: pnpm/action-setup@v6.0.4\n        with:\n          run_install: false\n      - uses: actions/setup-node@v6.4.0\n        with:\n          node-version-file: \"package.json\"\n          cache: \"pnpm\"\n      - run: pnpm install\n      - name: Build generated files\n        run: pnpm run build\n      - run: pnpm run biome-check\n      - run: pnpm run prettier-check\n      - run: pnpm run type-check\n      - run: pnpm test\n      - run: pnpm run knip\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules\nbuild\n.generated\n.env\n!.env.example\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\ncoverage"
  },
  {
    "path": ".prettierignore",
    "content": ".DS_Store\nnode_modules\n/build/\n/.generated/\n/package\n.env\n.env.*\n!.env.example\n\n# Ignore files for PNPM, NPM and YARN\npnpm-lock.yaml\npackage-lock.json\nyarn.lock\n\n# Let Biome handle these files\n*.ts\n*.tsx\n*.js\n*.json\n*.jsonc\n*.css\n*.html"
  },
  {
    "path": ".prettierrc",
    "content": "{\n\t\"useTabs\": true,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"all\",\n\t\"semi\": false,\n\t\"printWidth\": 100,\n\t\"plugins\": [\"prettier-plugin-svelte\", \"prettier-plugin-tailwindcss\"],\n\t\"tailwindStylesheet\": \"./src/app.css\",\n\t\"overrides\": [{ \"files\": \"*.svelte\", \"options\": { \"parser\": \"svelte\" } }]\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n\t\"recommendations\": [\"bradlc.vscode-tailwindcss\", \"biomejs.biome\", \"svelte.svelte-vscode\"]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n\t\"svelte.plugin.svelte.compilerWarnings\": {\n\t\t\"missing-declaration\": \"ignore\"\n\t},\n\t\"editor.codeActionsOnSave\": {\n\t\t\"source.organizeImports.biome\": \"explicit\",\n\t\t\"source.fixAll.biome\": \"explicit\"\n\t},\n\t\"[jsonc]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"[json]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"[javascript]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"[typescript]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"[css]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"[html]\": {\n\t\t\"editor.defaultFormatter\": \"biomejs.biome\"\n\t},\n\t\"editor.formatOnSave\": true,\n\t\"eslint.useESLintClass\": true,\n\t\"npm.packageManager\": \"pnpm\",\n\t\"js/ts.tsdk.path\": \"node_modules/typescript/lib\",\n\t\"files.associations\": {\n\t\t\"*.css\": \"tailwindcss\"\n\t},\n\t\"css.lint.unknownAtRules\": \"ignore\"\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Agent instructions\n\n## Project Overview\n\n**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.\n\n### Core Features\n\n- Local music playback using File System Access API or Files API fallback\n- Privacy-preserving (no data sent to servers)\n- IndexedDB for local storage and metadata\n- Web Workers for performance-intensive operations\n- Progressive Web App with offline support\n\n## Technology Stack\n\n### Frontend Framework\n\n- **SvelteKit 5** with Svelte Runes (`$state`, `$derived`, `$effect`)\n- **TypeScript** strict mode with no `any` types\n- **Tailwind CSS 4** with custom design system in `src/app.css`\n- **Vite 8** with Rolldown bundler\n\n### Core Dependencies\n\n```json\n{\n\t\"dependencies\": {\n\t\t\"@material/material-color-utilities\": \"^0.4.0\",\n\t\t\"@tanstack/virtual-core\": \"^3.13.23\",\n\t\t\"idb\": \"^8.0.3\",\n\t\t\"music-metadata\": \"^11.12.3\",\n\t\t\"tiny-invariant\": \"^1.3.3\",\n\t\t\"weak-lru-cache\": \"^1.2.2\"\n\t}\n}\n```\n\n### Development Tools\n\n- **pnpm** for package management\n- **Biome** for linting (primary)\n- **Prettier** for Svelte formatting\n- **Vitest** for testing with `fake-indexeddb`\n- **unplugin-auto-import** for global utilities\n- **@inlang/paraglide-js** for i18n (compiled to `.generated/paraglide/`)\n\n## File Organization\n\n```\nsrc/\n├── routes/\n│   ├── (app)/                     # Main application routes (with bottom bar)\n│   │   ├── library/               # Music library with slug-based entity views\n│   │   ├── player/                # Full-screen audio player (queue, history)\n│   │   ├── layout/                # Layout-level setup (install prompt, theme)\n│   │   └── (plain)/               # Routes without bottom nav bar\n│   │       ├── settings/          # App settings\n│   │       └── about/             # About page\n│   ├── (marketing)/               # Landing page\n│   └── (assets)/                  # Dynamic asset routes\n├── lib/\n│   ├── components/                # Reusable UI components\n│   │   ├── icon/                  # SVG icon system\n│   │   ├── tracks/                # Track list components\n│   │   ├── playlists/             # Playlist components\n│   │   ├── player/                # Player UI components\n│   │   ├── menu/                  # Context menu system\n│   │   ├── dialog/                # Modal dialogs\n│   │   ├── snackbar/              # Toast notifications\n│   │   ├── app-dialogs/           # App-level dialogs\n│   │   ├── library-grid/          # Library grid layout\n│   │   └── animated-icons/        # Animated icon components\n│   ├── stores/                    # Global state management\n│   │   ├── main/                  # App settings, theme (MainStore)\n│   │   ├── player/                # Audio playback state (PlayerStore)\n│   │   └── dialogs/               # Dialog state (DialogsStore)\n│   ├── db/                        # IndexedDB operations\n│   │   ├── query/                 # Reactive database queries\n│   │   ├── database.ts            # DB schema & connection\n│   │   └── events.ts              # DB change events\n│   ├── library/                   # Music library operations\n│   │   ├── scan-actions/          # File scanning and parsing\n│   │   ├── get/                   # Query helpers (ids, values)\n│   │   ├── playlists-actions.ts\n│   │   ├── play-history-actions.ts\n│   │   ├── tracks-queries.ts\n│   │   └── types.ts\n│   ├── helpers/                   # Utility functions\n│   └── attachments/               # Svelte element attachments (ripple, tooltip)\ntests/\n├── lib/\n│   └── library/                   # Library functionality tests\n└── shared.ts                      # Test utilities (clearDatabaseStores)\n```\n\n## Design System & Styling\n\n### Design Tokens\n\nUse design tokens from `src/app.css` and `src/theme-colors.css` — **never arbitrary values**.\n\nPrefer theme breakpoint variables in media queries, for example `@media (width >= --theme(--breakpoint-sm))`.\nUse a custom breakpoint only when there is no matching theme breakpoint for the behavior you need.\nIn Svelte component `<style>` blocks, add the appropriate `@reference` when using theme tokens such as `--theme(...)`.\n\n> **Critical**: Color token names use **camelCase**, not kebab-case.\n\n```css\n/* Colors - semantic, theme-aware (camelCase!) */\ncolor: var(--color-primary)\ncolor: var(--color-onSurface)              /* NOT --color-on-surface */\ncolor: var(--color-onSurfaceVariant)\nbackground: var(--color-surfaceContainer)  /* NOT --color-surface-container */\nbackground: var(--color-surfaceContainerHigh)\nbackground: var(--color-primaryContainer)\ncolor: var(--color-onPrimaryContainer)\n\n/* Spacing - use Tailwind spacing scale */\nmargin: --spacing(4)\npadding: --spacing(8)\ngap: --spacing(2)\n\n/* Typography - use utility classes, not font-size directly */\n/* Apply as CSS class: class=\"text-body-md\" */\n```\n\n### Required Patterns\n\n```html\n<!-- All clickable elements need .interactable -->\n<button class=\"interactable\">Click me</button>\n\n<!-- Container styling -->\n<div class=\"card\">Content</div>\n\n<!-- Touch feedback (from $lib/attachments/ripple.ts) -->\n<button {@attach ripple()}>Interactive</button>\n\n<!-- Tooltips (from $lib/attachments/tooltip.ts) -->\n<button {@attach tooltip('Help text')}>?</button>\n```\n\n### Typography Scale\n\nAvailable as CSS utility classes (defined in `src/app.css`):\n\n- `text-headline-lg` / `text-headline-md` / `text-headline-sm` - Headings\n- `text-title-lg` / `text-title-md` / `text-title-sm` - Component titles\n- `text-body-lg` / `text-body-md` / `text-body-sm` - Body text (default: `text-body-md`)\n- `text-label-lg` / `text-label-md` / `text-label-sm` - Labels\n\n## Auto-Imported Utilities\n\nThese are globally available without imports (configured in `vite.config.ts`). **Never import them manually.**\n\n```typescript\n// Internationalization (from @inlang/paraglide-js)\nm.tracks()          // m.albums(), m.settings(), etc.\n\n// Stores (context-based, call inside Svelte component tree)\nusePlayer()         // Audio player state (PlayerStore)\nuseMainStore()      // App settings, theme (MainStore)\nuseDialogsStore()   // Dialog state (DialogsStore)\nuseMenu()           // Context menus (MenuAPI)\n\n// Notifications\nsnackbar('Message text')              // Show toast\nsnackbar({ id: 'x', message: '...' }) // With options\nsnackbar.unexpectedError(error)        // Error toast\nsnackbar.dismiss('id')                 // Dismiss\n\n// Utilities\ninvariant(condition, 'message')  // Runtime assertions (tiny-invariant)\nuntrack(() => value)             // Svelte untrack\n```\n\nNote: `Snippet<T>` and `ClassValue` are **Svelte/TypeScript built-in types**, not auto-imports.\n\n## Component Development\n\n### Svelte 5 Component Pattern\n\n```svelte\n<script lang=\"ts\">\n\tinterface Props {\n\t\titems: string[]\n\t\tselectedId?: number\n\t\tonSelect?: (id: number) => void\n\t\tchildren?: Snippet<[string, number]>\n\t}\n\n\tconst { items, selectedId = 0, onSelect, children }: Props = $props()\n\n\tlet internalState = $state(selectedId)\n\tconst filteredItems = $derived(items.filter(Boolean))\n\n\t$effect(() => {\n\t\t// React to prop changes\n\t\tinternalState = selectedId\n\t})\n</script>\n\n<div class=\"card\">\n\t{#each filteredItems as item, i}\n\t\t<button\n\t\t\tclass=\"interactable\"\n\t\t\tclass:selected={i === internalState}\n\t\t\tonclick={() => {\n\t\t\t\tinternalState = i\n\t\t\t\tonSelect?.(i)\n\t\t\t}}\n\t\t\t{@attach ripple()}\n\t\t>\n\t\t\t{#if children}\n\t\t\t\t{@render children(item, i)}\n\t\t\t{:else}\n\t\t\t\t{item}\n\t\t\t{/if}\n\t\t</button>\n\t{/each}\n</div>\n\n<style>\n\t.selected {\n\t\tbackground: var(--color-primaryContainer);\n\t\tcolor: var(--color-onPrimaryContainer);\n\t}\n</style>\n```\n\n### Key Component Library\n\nAvailable in `src/lib/components/`:\n\n**Basic UI:**\n\n- `Button.svelte` - Primary/secondary buttons\n- `IconButton.svelte` - Icon-only buttons\n- `MenuButton.svelte` - Button that opens a context menu\n- `Icon.svelte` - SVG icon system\n- `TextField.svelte` - Text input fields\n- `Select.svelte` - Dropdown selects\n- `Switch.svelte` - Toggle switches\n- `Slider.svelte` - Range slider\n- `Tabs.svelte` - Tab navigation\n- `Spinner.svelte` - Loading indicator\n- `FavoriteButton.svelte` - Toggle favorite state\n\n**Layout:**\n\n- `Header.svelte` - Page headers\n- `BackButton.svelte` - Navigation back button\n- `Separator.svelte` - Visual dividers\n- `ScrollContainer.svelte` - Scrollable container\n- `VirtualContainer.svelte` - Virtual scrolling for large lists\n- `ListDetailsLayout.svelte` - Master-detail layout\n- `ListItem.svelte` - Generic list item\n\n**Music-specific:**\n\n- `Artwork.svelte` - Album/track artwork\n- `PlayerOverlay.svelte` - Mini player overlay\n- `TracksListContainer.svelte` - Virtual track lists (`src/lib/components/tracks/`)\n- `PlaylistListContainer.svelte` - Playlist list (`src/lib/components/playlists/`)\n- `AlbumsListContainer.svelte` - Albums grid/list\n- `ArtistListContainer.svelte` - Artists list\n\n## State Management\n\n### Store Architecture\n\nUses context-based stores with Svelte 5 runes:\n\n```typescript\n// Main application store\nconst mainStore = useMainStore()\nmainStore.theme                  // AppThemeOption: 'light' | 'dark' | 'auto'\nmainStore.isThemeDark            // boolean (derived)\nmainStore.motion                 // AppMotionOption: 'normal' | 'reduced' | 'auto'\nmainStore.isReducedMotion        // boolean (derived)\nmainStore.pickColorFromArtwork   // boolean\nmainStore.volumeSliderEnabled    // boolean\nmainStore.librarySplitLayoutEnabled // boolean\n\n// Audio player store\nconst player = usePlayer()\nplayer.playing          // boolean (true = playing)\nplayer.loading          // boolean (true = loading audio)\nplayer.activeTrack      // TrackData | undefined\nplayer.itemsIds         // readonly number[] (queue track IDs)\nplayer.currentTime      // number (seconds)\nplayer.duration         // number (seconds)\nplayer.volume           // number (0–100)\nplayer.muted            // boolean\nplayer.shuffle          // boolean\nplayer.repeat           // PlayerRepeat: 'none' | 'one' | 'all'\nplayer.equalizer        // EqualizerStore\nplayer.artworkSrc       // string | undefined\n\n// Player actions\nplayer.playTrack(trackIds, options?)  // Set queue and play\nplayer.togglePlay(force?)             // Toggle or force play/pause\nplayer.playNext()                     // Next track\nplayer.playPrev()                     // Previous track\nplayer.seek(time)                     // Seek to time in seconds\nplayer.toggleRepeat()                 // Cycle repeat mode\nplayer.toggleShuffle()                // Toggle shuffle\nplayer.addToQueue(trackId)            // Add track(s) to queue\nplayer.removeFromQueue(index)         // Remove by queue index\nplayer.clearQueue()                   // Empty the queue\n```\n\n### Persistence\n\nStores self-persist via the `persist()` helper (used internally in store constructors — do not call it for new ad-hoc values):\n\n```typescript\n// Inside a store class constructor\npersist('storeName', this, ['fieldA', 'fieldB'])\n// Keys are persisted to localStorage under snaeplayer-{storeName}.{key}\n```\n\n## Database Layer\n\n### Architecture\n\n- **IndexedDB** via `idb` library\n- **Reactive queries** that auto-update UI components\n- **Type-safe** operations\n- **Migration system** for schema changes\n\n### Database Schema\n\n```typescript\n// From $lib/library/types.ts\ninterface Track {\n\tid: number\n\tuuid: string\n\tname: string\n\tartists: StringOrUnknownItem[]\n\talbum: StringOrUnknownItem\n\tyear: StringOrUnknownItem\n\tduration: number\n\tgenre: string[]\n\ttrackNo: number\n\ttrackOf: number\n\tdiscNo: number\n\tdiscOf: number\n\tfileName: string\n\tdirectory: number   // FK to Directory.id; -1 = legacy no-native-directory\n\tscannedAt: number\n\tfile: FileEntity\n\timage?: { optimized: boolean; small: Blob; full: Blob }\n\tprimaryColor?: number\n}\n\ninterface Album {\n\tid: number\n\tuuid: string\n\tname: string\n\tartists: string[]\n\tyear?: string\n\timage?: Blob\n}\n\ninterface Artist {\n\tid: number\n\tuuid: string\n\tname: string\n}\n\ninterface Playlist {\n\tid: number\n\tuuid: string\n\tname: string\n\tdescription: string\n\tcreatedAt: number\n}\n\ninterface PlaylistEntry {\n\tid: number\n\tplaylistId: number\n\ttrackId: number\n\taddedAt: number\n}\n\ninterface PlayHistoryEntry {\n\tid: number\n\ttrackId: number\n\tplayedAt: number\n}\n\ninterface Directory {\n\tid: number\n\thandle: FileSystemDirectoryHandle\n}\n```\n\nStores: `tracks`, `albums`, `artists`, `playlists`, `playlistEntries`, `directories`, `playHistory`\n\nSpecial constants from `$lib/library/types.ts`:\n\n- `FAVORITE_PLAYLIST_ID = -1` — built-in favorites playlist (not user-modifiable)\n- `UNKNOWN_ITEM = '~\\0unknown'` — sentinel for unknown artist/album/year\n- `LEGACY_NO_NATIVE_DIRECTORY = -1` — for tracks without a directory handle\n\n### Database Operations\n\n```typescript\n// Basic operations\nimport { getDatabase } from '$lib/db/database.ts'\n\nconst db = await getDatabase()\nconst tracks = await db.getAll('tracks')\nconst track = await db.get('tracks', trackId)\n\n// Reactive queries\nimport { createPageQuery } from '$lib/db/query/page-query.svelte.ts'\n\nconst tracksQuery = createPageQuery({\n\tqueryFn: async () => {\n\t\tconst db = await getDatabase()\n\t\treturn await db.getAll('tracks')\n\t},\n\tonDatabaseChange: (changes) => {\n\t\t// Auto-refetch when tracks change\n\t\treturn changes.some((c) => c.storeName === 'tracks')\n\t},\n})\n```\n\n## Music Library Operations\n\n### File Scanning\n\n```typescript\n// Scanner architecture\nimport { scanTracks } from '$lib/library/scan-actions/scan-tracks.ts'\n\n// Scan new files\nawait scanTracks({\n\taction: 'scan-new-directory',\n\tfiles: fileEntities,\n})\n```\n\n### Playlist Management\n\n```typescript\nimport {\n\tdbCreatePlaylist,\n\tdbAddTracksToPlaylist,\n\tdbRemoveTracksFromPlaylist,\n\ttoggleFavoriteTrack,\n} from '$lib/library/playlists-actions.ts'\n\n// Create playlist\nconst playlistId = await dbCreatePlaylist('My Playlist', 'Description')\n\n// Add tracks\nawait dbAddTracksToPlaylist(playlistId, trackIds)\n\n// Favorites\nawait toggleFavoriteTrack(trackId) // Adds/removes from favorites\n```\n\n## Testing Guidelines\n\n### Test Structure\n\n```typescript\nimport { describe, it, expect, vi, afterEach } from 'vitest'\nimport { clearDatabaseStores } from '../../shared.ts'\n\ndescribe('component functionality', () => {\n\tafterEach(async () => {\n\t\tawait clearDatabaseStores()\n\t\tvi.clearAllMocks()\n\t})\n\n\tit('should handle user interaction correctly', async () => {\n\t\t// Test implementation\n\t})\n})\n```\n\n## Error Handling\n\n### Runtime Assertions\n\n```typescript\nimport { invariant } from 'tiny-invariant' // Auto-imported\n\n// Use for critical runtime checks\ninvariant(track, 'Track must be defined')\ninvariant(tracks.length > 0, 'Must have tracks to play')\n```\n\n### Error Boundaries\n\n```svelte\n<!-- +error.svelte for route-level errors -->\n<script>\n\timport { page } from '$app/state'\n\tconst { error } = $props()\n</script>\n\n<h1>Something went wrong</h1><p>{error.message}</p>\n```\n\n### Graceful Degradation\n\n```typescript\n// Feature detection\nif ('showDirectoryPicker' in window) {\n\t// Use File System Access API\n} else {\n\t// Fallback to File API\n}\n```\n\n## Development Workflow\n\n### Commands\n\n```bash\n# Development\npnpm run dev          # Start dev server\n\n# Building\npnpm run build        # Production build\npnpm run preview      # Preview build\n\n# Code Quality\npnpm run i18n-check   # Validate translations in messages/*.json\npnpm run type-check   # Type checking\npnpm run biome-check  # Linting\npnpm run biome-fix    # Fix linting issues\n\n# Testing\npnpm run test         # Run tests\n```\n\n### Code Quality Rules\n\n#### Always Do ✅\n\n- Use pnpm when running commands\n- Leverage auto-imports for common utilities\n- Use design system tokens, never arbitrary values\n- Apply `.interactable` class to all clickable elements\n- Leverage auto-imported utilities (don't import them)\n- Use Svelte 5 runes for reactive state\n- Type everything explicitly - avoid `any` types\n- Handle loading and error states\n- Include accessibility attributes\n- Use `invariant()` for runtime checks\n- Clear test mocks in `afterEach`\n- Run `pnpm run i18n-check` after adding/changing i18n keys\n- Keep i18n placeholders exactly aligned with English keys (`{count}`, `{name}`, etc.)\n\n#### Never Do ❌\n\n- Use arbitrary Tailwind classes for colors/spacing\n- Import auto-imported utilities (m, usePlayer, useMainStore, etc.)\n- Skip TypeScript strict mode checks\n- Ignore accessibility requirements\n- Add server-side dependencies except in `+server.ts` files\n- Use `any` types except for complex generics\n- Skip error handling\n- Hardcode strings (use i18n messages)\n\n### File Naming Conventions\n\n- **Components**: `PascalCase.svelte`\n- **Routes**: `+page.svelte`, `+layout.svelte`, `+page.ts`\n- **Types**: `kebab-case.ts`\n- **Stores**: `kebab-case.svelte.ts`\n\n## Key Files Reference\n\n### Configuration\n\n- `vite.config.ts` - Build and auto-import configuration\n- `svelte.config.js` - SvelteKit configuration\n- `biome.jsonc` - Code quality rules\n\n### Core Application\n\n- `src/app.css` - Design system and global styles\n- `src/theme-colors.css` - Color design tokens (camelCase names)\n- `src/app.d.ts` - Global TypeScript definitions\n- `src/app.html` - HTML template\n- `src/lib/stores/` - Global state management\n- `src/lib/db/database.ts` - IndexedDB setup\n\n## Marketing Copy\n\nFor 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`.\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "MIT License\r\n\r\nCopyright (c) 2019 Justinas Delinda\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n"
  },
  {
    "path": "README.md",
    "content": "# Snae Player\n\n**[snaeplayer.com](https://snaeplayer.com)** - Local music player in the browser.\n\nPlay audio files stored on your device. Includes playlists, queue, favorites, equalizer, playback speed, and artwork-based theming.\n\n<p align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/minht11/local-music-pwa/main/src/routes/(marketing)/assets/hero.avif\" height=\"400\" alt=\"Snae Player showing the music library and playback controls\" />\n</p>\n\n## Browser support\n\nWorks 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.\n\n## Privacy\n\nYour music files and library data stay on your device. The app does not collect or transmit them.\n\nPage views are counted using [GoatCounter](https://goatcounter.com/), a minimal privacy-preserving analytics tool.\n\n## Tech stack\n\nSvelteKit/Svelte 5 · TypeScript · Tailwind CSS 4\n\n## Building locally\n\nClone the repo, then:\n\n```\npnpm install\npnpm run build\n```\n\nOr run the development server:\n\n```\npnpm run dev\n```\n"
  },
  {
    "path": "biome.jsonc",
    "content": "{\n\t\"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n\t\"vcs\": {\n\t\t\"enabled\": true,\n\t\t\"clientKind\": \"git\",\n\t\t\"defaultBranch\": \"main\",\n\t\t\"useIgnoreFile\": true\n\t},\n\t\"assist\": {\n\t\t\"actions\": {\n\t\t\t\"source\": {\n\t\t\t\t\"organizeImports\": {\n\t\t\t\t\t\"level\": \"on\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"groups\": [\n\t\t\t\t\t\t\t\":URL:\",\n\t\t\t\t\t\t\t\":NODE:\",\n\t\t\t\t\t\t\t\":PACKAGE:\",\n\t\t\t\t\t\t\t\":PACKAGE_WITH_PROTOCOL:\",\n\t\t\t\t\t\t\t\"$app/**\",\n\t\t\t\t\t\t\t\"$server/**\",\n\t\t\t\t\t\t\t\"$lib/**\",\n\t\t\t\t\t\t\t\":ALIAS:\",\n\t\t\t\t\t\t\t\":PATH:\"\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t},\n\t\"files\": {\n\t\t\"includes\": [\n\t\t\t\"**\",\n\t\t\t\"!**/.generated\",\n\t\t\t// Biome parser incorrectly errors on TS syntax.\n\t\t\t\"!src/lib/components/VirtualContainer.svelte\",\n\t\t\t\"!static/supported-browser-check.js\"\n\t\t]\n\t},\n\t\"formatter\": {\n\t\t\"enabled\": true,\n\t\t\"lineWidth\": 100,\n\t\t\"includes\": [\"**\", \"!**/*.svelte\", \"**/*.svelte.ts\"]\n\t},\n\t\"linter\": {\n\t\t\"enabled\": true,\n\t\t\"domains\": {\n\t\t\t\"project\": \"recommended\"\n\t\t},\n\t\t\"rules\": {\n\t\t\t\"style\": {\n\t\t\t\t\"noNegationElse\": \"error\",\n\t\t\t\t\"useBlockStatements\": \"error\",\n\t\t\t\t\"useCollapsedElseIf\": \"error\",\n\t\t\t\t\"useConsistentArrayType\": {\n\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"syntax\": \"shorthand\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"useShorthandAssign\": \"error\",\n\t\t\t\t\"useFilenamingConvention\": {\n\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"requireAscii\": true,\n\t\t\t\t\t\t\"filenameCases\": [\"kebab-case\", \"export\", \"PascalCase\"]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"useThrowNewError\": \"error\",\n\t\t\t\t\"useThrowOnlyError\": \"error\",\n\t\t\t\t\"useConsistentBuiltinInstantiation\": \"error\",\n\t\t\t\t\"useLiteralEnumMembers\": \"error\",\n\t\t\t\t\"useNodejsImportProtocol\": \"error\",\n\t\t\t\t\"useAsConstAssertion\": \"error\",\n\t\t\t\t\"useEnumInitializers\": \"error\",\n\t\t\t\t\"useSelfClosingElements\": \"error\",\n\t\t\t\t\"useSingleVarDeclarator\": \"error\",\n\t\t\t\t\"noUnusedTemplateLiteral\": \"error\",\n\t\t\t\t\"useNumberNamespace\": \"error\",\n\t\t\t\t\"noInferrableTypes\": \"error\",\n\t\t\t\t\"useExponentiationOperator\": \"error\",\n\t\t\t\t\"useTemplate\": \"error\",\n\t\t\t\t\"noParameterAssign\": \"error\",\n\t\t\t\t\"noNonNullAssertion\": \"error\",\n\t\t\t\t\"useDefaultParameterLast\": \"error\",\n\t\t\t\t\"useExportType\": \"error\",\n\t\t\t\t\"noUselessElse\": \"error\",\n\t\t\t\t\"useShorthandFunctionType\": \"error\",\n\t\t\t\t\"useNumericSeparators\": \"error\",\n\t\t\t\t\"noSubstr\": \"error\",\n\t\t\t\t\"useTrimStartEnd\": \"error\",\n\t\t\t\t\"useObjectSpread\": \"error\",\n\t\t\t\t\"useGroupedAccessorPairs\": \"error\",\n\t\t\t\t\"useForOf\": \"error\",\n\t\t\t\t\"useDeprecatedReason\": \"error\",\n\t\t\t\t\"useAtIndex\": \"error\",\n\t\t\t\t\"noYodaExpression\": \"error\",\n\t\t\t\t\"useConsistentArrowReturn\": \"error\",\n\t\t\t\t\"useArrayLiterals\": \"error\",\n\t\t\t\t\"useCollapsedIf\": \"error\",\n\t\t\t\t\"useConsistentTypeDefinitions\": \"error\",\n\t\t\t\t\"useExplicitLengthCheck\": \"error\",\n\t\t\t\t\"noShoutyConstants\": \"error\",\n\t\t\t\t\"noRestrictedImports\": {\n\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"paths\": {\n\t\t\t\t\t\t\t\"@material/material-color-utilities\": \"Should not be used directly except in specific theme entrypoints to avoid breaking making big chunks\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"useConsistentObjectDefinitions\": \"error\"\n\t\t\t},\n\t\t\t\"correctness\": {\n\t\t\t\t\"noUndeclaredVariables\": \"error\",\n\t\t\t\t\"useImportExtensions\": \"error\",\n\t\t\t\t\"noPrivateImports\": {\n\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"defaultVisibility\": \"package\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"useSingleJsDocAsterisk\": \"error\",\n\t\t\t\t\"noUnknownFunction\": {\n\t\t\t\t\t\"level\": \"on\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"ignore\": [\"theme\"]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"nursery\": {\n\t\t\t\t\"noFloatingPromises\": \"error\",\n\t\t\t\t\"noMisusedPromises\": \"error\",\n\t\t\t\t\"noNestedPromises\": \"error\",\n\t\t\t\t\"noIncrementDecrement\": \"error\",\n\t\t\t\t\"noUnnecessaryConditions\": \"error\",\n\t\t\t\t\"noUselessReturn\": \"error\",\n\t\t\t\t\"useConsistentMethodSignatures\": \"error\"\n\t\t\t},\n\t\t\t\"complexity\": {\n\t\t\t\t\"useSimplifiedLogicExpression\": \"error\",\n\t\t\t\t\"useNumericLiterals\": \"error\",\n\t\t\t\t\"noCommaOperator\": \"error\",\n\t\t\t\t\"noImportantStyles\": \"off\"\n\t\t\t},\n\t\t\t\"suspicious\": {\n\t\t\t\t\"useAwait\": \"error\",\n\t\t\t\t\"useErrorMessage\": \"error\",\n\t\t\t\t\"noConsole\": {\n\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"allow\": [\"assert\", \"error\", \"info\", \"warn\", \"time\", \"timeEnd\", \"debug\"]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"noUnknownAtRules\": {\n\t\t\t\t\t\"level\": \"info\",\n\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\"ignore\": [\"slot\"]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"noImportCycles\": \"error\",\n\t\t\t\t\"noDeprecatedImports\": \"on\"\n\t\t\t},\n\t\t\t\"a11y\": {\n\t\t\t\t\"noSvgWithoutTitle\": \"off\"\n\t\t\t},\n\t\t\t\"performance\": {\n\t\t\t\t\"useTopLevelRegex\": \"error\"\n\t\t\t}\n\t\t}\n\t},\n\t\"javascript\": {\n\t\t\"formatter\": {\n\t\t\t\"semicolons\": \"asNeeded\",\n\t\t\t\"quoteStyle\": \"single\",\n\t\t\t\"jsxQuoteStyle\": \"single\",\n\t\t\t\"indentWidth\": 4\n\t\t},\n\t\t\"globals\": [\n\t\t\t\"m\",\n\t\t\t\"invariant\",\n\t\t\t\"usePlayer\",\n\t\t\t\"useMainStore\",\n\t\t\t\"useDialogsStore\",\n\t\t\t\"useMenu\",\n\t\t\t\"untrack\",\n\t\t\t\"snackbar\"\n\t\t]\n\t},\n\t\"json\": {\n\t\t\"formatter\": {\n\t\t\t\"indentWidth\": 2\n\t\t}\n\t},\n\t\"css\": {\n\t\t\"parser\": {\n\t\t\t\"tailwindDirectives\": true\n\t\t},\n\t\t\"formatter\": {\n\t\t\t\"enabled\": true,\n\t\t\t\"quoteStyle\": \"single\"\n\t\t}\n\t},\n\t\"html\": {\n\t\t\"experimentalFullSupportEnabled\": true,\n\t\t\"formatter\": {\n\t\t\t\"enabled\": true\n\t\t}\n\t},\n\t\"overrides\": [\n\t\t{\n\t\t\t\"includes\": [\"**/*.svelte\", \"!**/*.svelte.ts\"],\n\t\t\t\"linter\": {\n\t\t\t\t\"rules\": {\n\t\t\t\t\t\"correctness\": {\n\t\t\t\t\t\t\"noUnusedVariables\": \"off\",\n\t\t\t\t\t\t\"noUnusedFunctionParameters\": \"off\"\n\t\t\t\t\t},\n\t\t\t\t\t\"complexity\": {\n\t\t\t\t\t\t\"noCommaOperator\": \"off\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"includes\": [\"**/src/params/**\"],\n\t\t\t\"linter\": {\n\t\t\t\t\"rules\": {\n\t\t\t\t\t\"style\": {\n\t\t\t\t\t\t\"useFilenamingConvention\": \"off\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"includes\": [\"**/*.test.ts\"],\n\t\t\t\"linter\": {\n\t\t\t\t\"rules\": {\n\t\t\t\t\t\"correctness\": {\n\t\t\t\t\t\t\"noPrivateImports\": {\n\t\t\t\t\t\t\t\"level\": \"error\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"defaultVisibility\": \"public\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"includes\": [\"messages/*.json\", \"src/lib/components/icon/icon-paths.server.ts\"],\n\t\t\t\"assist\": {\n\t\t\t\t\"actions\": {\n\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\"useSortedKeys\": {\n\t\t\t\t\t\t\t\"level\": \"on\",\n\t\t\t\t\t\t\t\"options\": {}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "knip.json",
    "content": "{\n\t\"$schema\": \"https://unpkg.com/knip@6/schema.json\",\n\t\"tags\": [\"-lintignore\"],\n\t\"entry\": [\n\t\t\"src/routes/**/*.{svelte,ts}\",\n\t\t\"src/lib/stores/**/*\",\n\t\t\".generated/types/auto-imports.d.ts\"\n\t],\n\t\"project\": [\"**/*\", \"!src/paraglide/**/*\", \"!static/supported-browser-check.js\"],\n\t\"ignoreExportsUsedInFile\": {\n\t\t\"interface\": true,\n\t\t\"type\": true\n\t},\n\t\"ignoreUnresolved\": [\"^\\\\./\\\\$types\\\\.ts$\"]\n}\n"
  },
  {
    "path": "lib/vite-image-metadata.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { imageSizeFromFile } from 'image-size/fromFile'\nimport type { Plugin } from 'vite'\n\nconst imageQuery = '?as=metadata'\nconst allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']\n\nconst queryRegex = /\\?as=metadata$/\n\n/** @public */\nexport function imageMetadataPlugin(): Plugin {\n\treturn {\n\t\tname: 'vite-plugin-image-metadata',\n\t\tenforce: 'pre',\n\t\tload: {\n\t\t\tfilter: {\n\t\t\t\tid: queryRegex,\n\t\t\t},\n\t\t\tasync handler(id) {\n\t\t\t\tconst filePath = id.replace(imageQuery, '')\n\n\t\t\t\tconst ext = path.extname(filePath).slice(1)\n\t\t\t\tif (!allowedExts.includes(ext)) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif (!fs.existsSync(filePath)) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst dimensions = await imageSizeFromFile(filePath)\n\n\t\t\t\treturn `\n          import src from \"${filePath}?url\";\n\n          export const width = ${dimensions.width};\n          export const height = ${dimensions.height};\n          export { src };\n\n          export default { src, width, height };\n        `\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "lib/vite-log-chunk-size.ts",
    "content": "import { readdirSync, statSync } from 'node:fs'\nimport path from 'node:path'\nimport type { Plugin } from 'vite'\n\n/** @public */\nexport const logChunkSizePlugin = (): Plugin => ({\n\tname: 'vite-plugin-log-chunk-size',\n\tapply: 'build',\n\tenforce: 'post',\n\twriteBundle() {\n\t\tif (this.environment.name === 'ssr') {\n\t\t\treturn\n\t\t}\n\n\t\tconst dirSize = async (directory: string) => {\n\t\t\tconst jsInfo = { size: 0, count: 0 }\n\t\t\tconst totalInfo = { size: 0, count: 0 }\n\n\t\t\tconst processDirectory = async (dir: string) => {\n\t\t\t\tconst files = readdirSync(dir)\n\n\t\t\t\tfor (const file of files) {\n\t\t\t\t\tconst filePath = path.join(dir, file)\n\t\t\t\t\tconst stat = statSync(filePath)\n\n\t\t\t\t\tif (stat.isDirectory()) {\n\t\t\t\t\t\tawait processDirectory(filePath)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (file.endsWith('.js')) {\n\t\t\t\t\t\t\tjsInfo.size += stat.size\n\t\t\t\t\t\t\tjsInfo.count += 1\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttotalInfo.size += stat.size\n\t\t\t\t\t\ttotalInfo.count += 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait processDirectory(directory)\n\n\t\t\treturn { jsInfo, totalInfo }\n\t\t}\n\n\t\tsetTimeout(async () => {\n\t\t\tconst { jsInfo, totalInfo } = await dirSize('./build/_app/immutable')\n\t\t\tconsole.info('Size of JS chunks:', jsInfo.size / 1024, 'KB. Files count:', jsInfo.count)\n\t\t\tconsole.info(\n\t\t\t\t'Size of all files:',\n\t\t\t\ttotalInfo.size / 1024,\n\t\t\t\t'KB. Files count:',\n\t\t\t\ttotalInfo.count,\n\t\t\t)\n\t\t}, 2000)\n\t},\n})\n"
  },
  {
    "path": "messages/de.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"Über\",\n\t\"aboutHomepage\": \"Webseite\",\n\t\"aboutJoinDiscord\": \"Discord\",\n\t\"aboutPrivacy\": \"Privatsphäre\",\n\t\"aboutSourceCode\": \"Quellcode\",\n\n\t\"album\": \"Album\",\n\t\"albums\": \"Alben\",\n\n\t\"appName\": \"Snae Player\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"Neue Version verfügbar\",\n\n\t\"artist\": \"Künstler\",\n\t\"artists\": \"Künstler\",\n\t\"cancel\": \"Abbrechen\",\n\t\"created\": \"Erstellt\",\n\t\"description\": \"Beschreibung\",\n\t\"directoryIsIncludedInParent\": \"\\\"{newDir}\\\" ist ein Unterverzeichnis von \\\"{existingDir}\\\" das bereits in Ihrer Bibliothek vorhanden ist. Eine erneute Hinzufügung ist nicht erforderlich.\",\n\t\"dismiss\": \"Schließen\",\n\t\"duration\": \"Dauer\",\n\t\"equalizerClose\": \"Schließen\",\n\t\"equalizerOpenEqualizer\": \"Equalizer öffnen\",\n\t\"equalizerPresetAcoustic\": \"Akustisch\",\n\t\"equalizerPresetBassBoost\": \"Bassverstärkung\",\n\t\"equalizerPresetClassical\": \"Klassik\",\n\t\"equalizerPresetElectronic\": \"Elektronisch\",\n\t\"equalizerPresetFlat\": \"Neutral\",\n\t\"equalizerPresetJazz\": \"Jazz\",\n\t\"equalizerPresetPop\": \"Pop\",\n\t\"equalizerPresetRock\": \"Rock\",\n\t\"equalizerPresetTrebleBoost\": \"Höhenverstärkung\",\n\t\"equalizerReset\": \"Zurücksetzen\",\n\t\"equalizerStatusEnabled\": \"Aktiviert\",\n\t\"equalizerTitle\": \"Equalizer\",\n\n\t\"errorPageDoesNotExist\": \"Diese Seite scheint nicht zu existieren.\",\n\t\"errorUnexpected\": \"Es ist ein unerwarteter Fehler aufgetreten.\",\n\t\"favorites\": \"Favoriten\",\n\t\"foundAnIssue\": \"Haben Sie einen Fehler entdeckt?\",\n\t\"goBack\": \"Zurück\",\n\t\"goHome\": \"Zur Startseite\",\n\t\"library\": \"Bibliothek\",\n\t\"libraryAddToPlaylist\": \"Zur Wiedergabeliste hinzufügen\",\n\t\"libraryApplicationMenu\": \"App-Menü\",\n\t\"libraryCancel\": \"Abbrechen\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"Sind Sie sicher, dass Sie diese {count} Elemente entfernen möchten?\",\n\t\"libraryConfirmRemoveTitle\": \"Sind Sie sicher, dass Sie \\\"{name}\\\" entfernen möchten?\",\n\t\"libraryCreate\": \"Erstellen\",\n\t\"libraryCreateNewPlaylist\": \"Neue Playlist\",\n\t\"libraryDirPromptBrowserPermission\": \"Browserberechtigung erforderlich\",\n\t\"libraryDirPromptExplanation\": \"Zum Abspielen von Musik benötigt die App Zugriff auf folgende Verzeichnisse:\",\n\t\"libraryDirPromptGrant\": \"Zulassen\",\n\n\t\"libraryEditPlaylist\": \"Wiedergabeliste bearbeiten\",\n\t\"libraryEditPlaylistName\": \"Wiedergabelistenname bearbeiten\",\n\t\"libraryEmpty\": \"Ihre Bibliothek ist leer\",\n\t\"libraryImportTracks\": \"Titel importieren\",\n\t\"libraryItemRemovedFromLibrary\": \"Element aus der Bibliothek entfernt\",\n\t\"libraryItemsRemovedFromLibrary\": \"Elemente aus der Bibliothek entfernt\",\n\t\"libraryNewPlaylist\": \"Neue Wiedergabeliste\",\n\n\t\"libraryNoResults\": \"Keine Ergebnisse gefunden\",\n\t\"libraryNoResultsExplanation\": \"Versuchen Sie es mit einer anderen Suche\",\n\t\"libraryOpenApplicationMenu\": \"Verwaltung\",\n\t\"libraryOpenSortMenu\": \"Sortieren nach\",\n\t\"libraryPlaylistCreated\": \"Wiedergabeliste \\\"{playlistName}\\\" erstellt\",\n\t\"libraryPlaylistFieldName\": \"Wiedergabelistenname\",\n\t\"libraryPlaylistName\": \"Wiedergabelistenname\",\n\t\"libraryPlaylistRemoved\": \"Wiedergabeliste entfernt\",\n\t\"libraryPlaylistsUpdated\": \"Wiedergabelisten aktualisiert\",\n\t\"libraryPlaylistUpdated\": \"Wiedergabelisten aktualisiert\",\n\t\"libraryRemove\": \"Entfernen\",\n\t\"libraryRemoveFromLibrary\": \"Aus der Bibliothek entfernen\",\n\t\"librarySave\": \"Speichern\",\n\n\t\"librarySearch\": \"Suchen\",\n\t\"librarySelectSomethingToBeShown\": \"Bitte wählen Sie einen Listeneintrag aus, der hier angezeigt werden soll\",\n\t\"librarySplitViewDisable\": \"Geteilte Ansicht deaktivieren\",\n\t\"librarySplitViewEnable\": \"Geteilte Ansicht aktivieren\",\n\t\"libraryStartByAdding\": \"Fügen Sie jetzt Musik hinzu\",\n\t\"libraryToggleSortOrder\": \"Sortierreihenfolge ändern\",\n\t\"libraryTrackRemovedFromPlaylist\": \"Titel aus der Wiedergabeliste entfernt\",\n\n\t\"libraryTrackRemoveFromPlaylist\": \"Aus Playlist entfernen\",\n\t\"libraryTracksCount\": \"{count} Titel\",\n\t\"libraryViewDetails\": \"Details anzeigen\",\n\t\"more\": \"Mehr\",\n\t\"moreOptions\": \"Weitere Optionen\",\n\t\"name\": \"Name\",\n\t\"noItemsToDisplay\": \"Keine Elemente vorhanden\",\n\t\"pause\": \"Pause\",\n\t\"play\": \"Abspielen\",\n\n\t\"player\": \"Player\",\n\t\"playerAddToQueue\": \"Zur Warteschlange hinzufügen\",\n\t\"playerAudioErrorLoadError\": \"Audio für \\\"{name}\\\" konnte nicht geladen werden\",\n\t\"playerAudioErrorNotFound\": \"Audiodatei für \\\"{name}\\\" nicht gefunden. Sie wurde möglicherweise verschoben oder gelöscht.\",\n\t\"playerAudioErrorPermissionDenied\": \"Keine Berechtigung, Audio für \\\"{name}\\\" zu laden. Bitte erteilen Sie die Browser-Berechtigung und versuchen Sie es erneut.\",\n\t\"playerClearHistory\": \"Verlauf löschen\",\n\t\"playerClearQueue\": \"Warteschlange löschen\",\n\t\"playerDecreaseVolume\": \"Lautstärke verringern\",\n\t\"playerDisableRepeat\": \"Wiederholung deaktivieren\",\n\t\"playerDisableShuffle\": \"Zufallswiedergabe deaktivieren\",\n\t\"playerEnableRepeat\": \"Wiederholung aktivieren\",\n\t\"playerEnableRepeatOne\": \"Einzelwiederholung aktivieren\",\n\t\"playerEnableShuffle\": \"Zufallswiedergabe aktivieren\",\n\t\"playerHistory\": \"Verlauf\",\n\t\"playerHistoryEmpty\": \"Ihr Wiedergabeverlauf ist leer\",\n\t\"playerIncreaseVolume\": \"Lautstärke erhöhen\",\n\t\"playerOpenFullPlayer\": \"Vollansicht öffnen\",\n\t\"playerOpenHistory\": \"Verlauf öffnen\",\n\t\"playerOpenQueue\": \"Warteschlange öffnen\",\n\t\"playerPause\": \"Pause\",\n\t\"playerPlay\": \"Abspielen\",\n\t\"playerPlayNextTrack\": \"Nächsten Titel abspielen\",\n\t\"playerPlayPreviousTrack\": \"Vorherigen Titel abspielen\",\n\t\"playerQueueEmpty\": \"Ihre Warteschlange ist leer\",\n\t\"playerQueuePlaySomething\": \"Musik entdecken\",\n\t\"playerRemoveFromHistory\": \"Aus Verlauf entfernen\",\n\t\"playerRemoveFromQueue\": \"Aus der Warteschlange entfernen\",\n\t\"playlist\": \"Wiedergabeliste\",\n\t\"playlists\": \"Wiedergabelisten\",\n\t\"queue\": \"Warteschlange\",\n\t\"reload\": \"Aktualisieren\",\n\t\"replace\": \"Ersetzen\",\n\t\"replaceDirectoryExplanation\": \"{newDir} ist ein übergeordnetes Verzeichnis von {existingDirs} und bereits in Ihrer Bibliothek enthalten.\\n Bestehende Titel in Ihrer Bibliothek bleiben unverändert.\",\n\n\t\"replaceDirectoryQ\": \"Ordner ersetzen?\",\n\t\"selectAll\": \"Alle auswählen\",\n\t\"selectedCount\": \"{count} ausgewählt\",\n\t\"settingPickColorFromArtwork\": \"Farbanpassung basierend auf dem aktuellen Titel\",\n\t\"settings\": \"Einstellungen\",\n\t\"settingsAbout\": \"Über\",\n\n\t\"settingsAddDirectory\": \"Ordner hinzufügen\",\n\n\t\"settingsAllDataLocal\": \"Alle Daten bleiben lokal auf Ihrem Gerät\",\n\t\"settingsAppearance\": \"Oberflächendesign\",\n\t\"settingsApplicationTheme\": \"Erscheinungsbild\",\n\t\"settingsColorPick\": \"Farbwahl\",\n\t\"settingsColorReset\": \"Zurücksetzen\",\n\t\"settingsDbOperationInProgress\": \"Datenbankvorgang läuft ...\",\n\t\"settingsDirectories\": \"Ordner\",\n\t\"settingsDirectoriesTracksCount\": \"{count} Titel\",\n\t\"settingsDirectoryRemoved\": \"Ordner entfernt\",\n\t\"settingsDirRemove\": \"Entfernen\",\n\t\"settingsDirRescan\": \"Erneut scannen\",\n\t\"settingsDisplayVolumeSlider\": \"Lautstärkeregler im Player anzeigen\",\n\t\"settingsGrantDirectoryAccess\": \"Bitte erlauben Sie der App den Ordnerzugriff über die Browser-Berechtigungen, damit die Inhalte gescannt werden können\",\n\t\"settingsImportTracks\": \"Titel importieren\",\n\t\"settingsInstallAppDesktop\": \"Desktop\",\n\t\"settingsInstallAppExplanation\": \"Fügen Sie den Snae Player zu Ihrem {device} hinzu, um ein noch intensiveres Erlebnis zu erhalten\",\n\t\"settingsInstallAppHomeAction\": \"Installieren\",\n\t\"settingsInstallAppHomeScreen\": \"Startbildschirm\",\n\t\"settingsLanguage\": \"Sprachen\",\n\t\"settingsMissingFs1\": \"Ihr Browser unterstützt nicht die erforderlichen \",\n\t\"settingsMissingFs2\": \"Dateisystemfunktionen,\",\n\t\"settingsMissingFs3\": \" für den vollständigen Ordnerzugriff, damit diese App funktioniert,\",\n\t\"settingsMissingFs4\": \"jede Musikdatei muss kopiert und im App-Speicher gespeichert werden,\",\n\t\"settingsMissingFs5\": \" dies könnte viel Speicherplatz auf Ihrem Gerät beanspruchen.\",\n\t\"settingsMotion\": \"Animation\",\n\t\"settingsMotionAuto\": \"Auto\",\n\t\"settingsMotionNormal\": \"Normal\",\n\t\"settingsMotionReduced\": \"Reduziert\",\n\t\"settingsPlaybackSpeed\": \"Wiedergabegeschwindigkeit\",\n\t\"settingsPlaybackSpeedReset\": \"Geschwindigkeit zurücksetzen\",\n\t\"settingsPreparingForScan\": \"Scan wird vorbereitet\",\n\t\"settingsPreservePitch\": \"Tonhöhe beibehalten\",\n\t\"settingsPreservePitchInfo\": \"Hält Stimmen und Instrumente in ihrer ursprünglichen Tonlage, während sich die Wiedergabegeschwindigkeit ändert.\",\n\t\"settingsPrimaryColor\": \"Primärfarbe der App\",\n\t\"settingsScanInProgress\": \"Titel werden gescannt. {current} von {total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"{newTracks} neue oder aktualisierte Titel gefunden\",\n\t\"settingsScanNoNewTracks\": \"Keine neuen Titel gefunden\",\n\t\"settingsThemeAuto\": \"Auto\",\n\t\"settingsThemeDark\": \"Dunkel\",\n\t\"settingsThemeLight\": \"Hell\",\n\t\"settingsTracksInAppStorageTooltip\": \"Dies enthält im App-Speicher gespeicherte Titel und/oder Daten, die Sie aus Snae Player V1 migriert haben\",\n\t\"settingsTracksInsideAppMemory\": \"Titel im App-Speicher\",\n\t\"shuffle\": \"Zufallswiedergabe\",\n\t\"successfullyRemovedTracks\": \"{count} Titel erfolgreich entfernt\",\n\t\"track\": \"Titel\",\n\t\"trackAddToFavorites\": \"Zu Favoriten hinzufügen\",\n\n\t\"trackPlay\": \"{name} abspielen\",\n\t\"trackRemoveFromFavorites\": \"Aus den Favoriten entfernen\",\n\t\"tracks\": \"Titel\",\n\t\"trackViewAlbum\": \"Album anzeigen\",\n\t\"trackViewArtist\": \"Künstler anzeigen\",\n\t\"understood\": \"Verstanden\",\n\t\"unknown\": \"Unbekannt\",\n\t\"validationMaxLength\": \"Es sind maximal {max} Zeichen erlaubt\",\n\t\"validationMinLength\": \"Es sind mindestens {min} Zeichen erforderlich\",\n\n\t\"validationRequired\": \"Eingabe erforderlich\",\n\t\"year\": \"Jahr\"\n}\n"
  },
  {
    "path": "messages/en.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"About\",\n\t\"aboutHomepage\": \"Homepage\",\n\t\"aboutJoinDiscord\": \"Join our Discord\",\n\t\"aboutPrivacy\": \"Privacy\",\n\t\"aboutSourceCode\": \"Source code\",\n\n\t\"album\": \"Album\",\n\t\"albums\": \"Albums\",\n\n\t\"appName\": \"Snae Player\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"App update is available\",\n\n\t\"artist\": \"Artist\",\n\t\"artists\": \"Artists\",\n\t\"cancel\": \"Cancel\",\n\t\"created\": \"Created\",\n\t\"description\": \"Description\",\n\t\"directoryIsIncludedInParent\": \"\\\"{newDir}\\\" is subdirectory of \\\"{existingDir}\\\" which is already in your Library. You do not need to add it again.\",\n\t\"dismiss\": \"Dismiss\",\n\t\"duration\": \"Duration\",\n\n\t\"equalizerClose\": \"Close\",\n\t\"equalizerOpenEqualizer\": \"Open equalizer\",\n\t\"equalizerPresetAcoustic\": \"Acoustic\",\n\t\"equalizerPresetBassBoost\": \"Bass Boost\",\n\t\"equalizerPresetClassical\": \"Classical\",\n\t\"equalizerPresetElectronic\": \"Electronic\",\n\t\"equalizerPresetFlat\": \"Flat\",\n\t\"equalizerPresetJazz\": \"Jazz\",\n\t\"equalizerPresetPop\": \"Pop\",\n\t\"equalizerPresetRock\": \"Rock\",\n\t\"equalizerPresetTrebleBoost\": \"Treble Boost\",\n\t\"equalizerReset\": \"Reset\",\n\t\"equalizerStatusEnabled\": \"Enabled\",\n\t\"equalizerTitle\": \"Equalizer\",\n\n\t\"errorPageDoesNotExist\": \"Looks like this page doesn't exist.\",\n\t\"errorUnexpected\": \"An unexpected error occurred.\",\n\t\"favorites\": \"Favorites\",\n\t\"foundAnIssue\": \"Found an issue?\",\n\t\"goBack\": \"Go back\",\n\t\"goHome\": \"Go home\",\n\t\"library\": \"Library\",\n\t\"libraryAddToPlaylist\": \"Add to playlist\",\n\t\"libraryApplicationMenu\": \"Application menu\",\n\t\"libraryCancel\": \"Cancel\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"Are you sure you want to remove these {count} items?\",\n\t\"libraryConfirmRemoveTitle\": \"Are you sure you want to remove \\\"{name}\\\"?\",\n\t\"libraryCreate\": \"Create\",\n\t\"libraryCreateNewPlaylist\": \"Create new playlist\",\n\t\"libraryDirPromptBrowserPermission\": \"Browser permission required\",\n\t\"libraryDirPromptExplanation\": \"To play music, the app needs permission to access these directories:\",\n\t\"libraryDirPromptGrant\": \"Grant\",\n\n\t\"libraryEditPlaylist\": \"Edit playlist\",\n\t\"libraryEditPlaylistName\": \"Edit playlist name\",\n\t\"libraryEmpty\": \"Your library is empty\",\n\t\"libraryImportTracks\": \"Import tracks\",\n\t\"libraryItemRemovedFromLibrary\": \"Item removed from library\",\n\t\"libraryItemsRemovedFromLibrary\": \"Items removed from library\",\n\t\"libraryNewPlaylist\": \"New playlist\",\n\n\t\"libraryNoResults\": \"No results found\",\n\t\"libraryNoResultsExplanation\": \"Try searching for something else\",\n\t\"libraryOpenApplicationMenu\": \"Open application menu\",\n\t\"libraryOpenSortMenu\": \"Open sort menu\",\n\t\"libraryPlaylistCreated\": \"Playlist \\\"{playlistName}\\\" created\",\n\t\"libraryPlaylistFieldName\": \"playlist name\",\n\t\"libraryPlaylistName\": \"Playlist name\",\n\t\"libraryPlaylistRemoved\": \"Playlist removed\",\n\t\"libraryPlaylistsUpdated\": \"Playlists updated\",\n\t\"libraryPlaylistUpdated\": \"Playlist updated\",\n\t\"libraryRemove\": \"Remove\",\n\t\"libraryRemoveFromLibrary\": \"Remove from library\",\n\t\"librarySave\": \"Save\",\n\n\t\"librarySearch\": \"Search\",\n\t\"librarySelectSomethingToBeShown\": \"Select something from the list to be shown here\",\n\t\"librarySplitViewDisable\": \"Disable split view layout\",\n\t\"librarySplitViewEnable\": \"Enable split view layout\",\n\t\"libraryStartByAdding\": \"Start by adding some music\",\n\t\"libraryToggleSortOrder\": \"Toggle sort order\",\n\t\"libraryTrackRemovedFromPlaylist\": \"Track removed from playlist\",\n\n\t\"libraryTrackRemoveFromPlaylist\": \"Remove from playlist\",\n\t\"libraryTracksCount\": \"{count} tracks\",\n\t\"libraryViewDetails\": \"View details\",\n\t\"more\": \"More\",\n\t\"moreOptions\": \"More options\",\n\t\"name\": \"Name\",\n\t\"noItemsToDisplay\": \"No items to display\",\n\t\"pause\": \"Pause\",\n\t\"play\": \"Play\",\n\n\t\"player\": \"Player\",\n\t\"playerAddToQueue\": \"Add to queue\",\n\t\"playerAudioErrorLoadError\": \"Failed to load audio for \\\"{name}\\\"\",\n\t\"playerAudioErrorNotFound\": \"Audio file not found for \\\"{name}\\\". It might have been moved or deleted.\",\n\t\"playerAudioErrorPermissionDenied\": \"Permission denied to load audio for \\\"{name}\\\". Please grant browser permission and try again.\",\n\t\"playerClearHistory\": \"Clear history\",\n\t\"playerClearQueue\": \"Clear queue\",\n\t\"playerDecreaseVolume\": \"Decrease volume\",\n\t\"playerDisableRepeat\": \"Disable repeat\",\n\t\"playerDisableShuffle\": \"Disable shuffle\",\n\t\"playerEnableRepeat\": \"Enable repeat\",\n\t\"playerEnableRepeatOne\": \"Enable repeat one\",\n\t\"playerEnableShuffle\": \"Enable shuffle\",\n\t\"playerHistory\": \"History\",\n\t\"playerHistoryEmpty\": \"Your play history is empty\",\n\t\"playerIncreaseVolume\": \"Increase volume\",\n\t\"playerOpenFullPlayer\": \"Open full player\",\n\t\"playerOpenHistory\": \"Open history\",\n\t\"playerOpenQueue\": \"Open queue\",\n\t\"playerPause\": \"Pause\",\n\t\"playerPlay\": \"Play\",\n\t\"playerPlayNextTrack\": \"Play next track\",\n\t\"playerPlayPreviousTrack\": \"Play previous track\",\n\t\"playerQueueEmpty\": \"Your queue is empty\",\n\t\"playerQueuePlaySomething\": \"Play something here\",\n\t\"playerRemoveFromHistory\": \"Remove from history\",\n\t\"playerRemoveFromQueue\": \"Remove from queue\",\n\t\"playlist\": \"Playlist\",\n\t\"playlists\": \"Playlists\",\n\t\"queue\": \"Queue\",\n\t\"reload\": \"Reload\",\n\t\"replace\": \"Replace\",\n\t\"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.\",\n\n\t\"replaceDirectoryQ\": \"Replace directory?\",\n\t\"selectAll\": \"Select all\",\n\t\"selectedCount\": \"Selected {count}\",\n\t\"settingPickColorFromArtwork\": \"Automatically pick color from currently playing song artwork\",\n\t\"settings\": \"Settings\",\n\t\"settingsAbout\": \"About\",\n\n\t\"settingsAddDirectory\": \"Add directory\",\n\n\t\"settingsAllDataLocal\": \"All data is kept on your device\",\n\t\"settingsAppearance\": \"Appearance\",\n\t\"settingsApplicationTheme\": \"Application theme\",\n\t\"settingsColorPick\": \"Pick color\",\n\t\"settingsColorReset\": \"Reset\",\n\t\"settingsDbOperationInProgress\": \"Database operation in progress...\",\n\t\"settingsDirectories\": \"Directories\",\n\t\"settingsDirectoriesTracksCount\": \"{count} tracks\",\n\t\"settingsDirectoryRemoved\": \"Directory removed\",\n\t\"settingsDirRemove\": \"Remove\",\n\t\"settingsDirRescan\": \"Rescan\",\n\t\"settingsDisplayVolumeSlider\": \"Display volume slider inside player\",\n\t\"settingsGrantDirectoryAccess\": \"You need to allow the app access to the directory, via browser permission so it can scan its contents\",\n\t\"settingsImportTracks\": \"Import tracks\",\n\t\"settingsInstallAppDesktop\": \"desktop\",\n\t\"settingsInstallAppExplanation\": \"Add Snae Player to your {device} for more immersive experience\",\n\t\"settingsInstallAppHomeAction\": \"Install\",\n\t\"settingsInstallAppHomeScreen\": \"home screen\",\n\t\"settingsLanguage\": \"Language\",\n\t\"settingsMissingFs1\": \"Your browser does not support required \",\n\t\"settingsMissingFs2\": \"File System features,\",\n\t\"settingsMissingFs3\": \" for full directory access so in order for this app to work,\",\n\t\"settingsMissingFs4\": \"each music file must be copied and saved inside app storage,\",\n\t\"settingsMissingFs5\": \" that might take up a lot of your device's disk space.\",\n\t\"settingsMotion\": \"Motion\",\n\t\"settingsMotionAuto\": \"Auto\",\n\t\"settingsMotionNormal\": \"Normal\",\n\t\"settingsMotionReduced\": \"Reduced\",\n\t\"settingsPlaybackSpeed\": \"Playback speed\",\n\t\"settingsPlaybackSpeedReset\": \"Reset speed\",\n\t\"settingsPreparingForScan\": \"Preparing for the scan\",\n\t\"settingsPreservePitch\": \"Preserve pitch\",\n\t\"settingsPreservePitchInfo\": \"Keeps voices and instruments at their original tone while changing playback speed.\",\n\t\"settingsPrimaryColor\": \"Application primary color\",\n\t\"settingsScanInProgress\": \"Scanning tracks. {current} of {total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"Found {newTracks} new or updated tracks\",\n\t\"settingsScanNoNewTracks\": \"No new tracks were found\",\n\t\"settingsThemeAuto\": \"Auto\",\n\t\"settingsThemeDark\": \"Dark\",\n\t\"settingsThemeLight\": \"Light\",\n\t\"settingsTracksInAppStorageTooltip\": \"This contains tracks stored in app storage and/or data you migrated from Snae Player v1\",\n\t\"settingsTracksInsideAppMemory\": \"Tracks inside app storage\",\n\n\t\"shuffle\": \"Shuffle\",\n\t\"successfullyRemovedTracks\": \"Successfully removed {count} tracks\",\n\t\"track\": \"Track\",\n\t\"trackAddToFavorites\": \"Add to favorites\",\n\n\t\"trackPlay\": \"Play {name}\",\n\t\"trackRemoveFromFavorites\": \"Remove from favorites\",\n\t\"tracks\": \"Tracks\",\n\t\"trackViewAlbum\": \"View album\",\n\t\"trackViewArtist\": \"View artist\",\n\t\"understood\": \"Understood\",\n\t\"unknown\": \"Unknown\",\n\t\"validationMaxLength\": \"At most {max} characters are allowed\",\n\t\"validationMinLength\": \"At least {min} characters are required\",\n\n\t\"validationRequired\": \"Field is required\",\n\t\"year\": \"Year\"\n}\n"
  },
  {
    "path": "messages/fr.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\t\"about\": \"À propos\",\n\t\"aboutHomepage\": \"Page d’accueil\",\n\t\"aboutJoinDiscord\": \"Rejoindre notre Discord\",\n\t\"aboutPrivacy\": \"Confidentialité\",\n\t\"aboutSourceCode\": \"Code source\",\n\t\"album\": \"Album\",\n\t\"albums\": \"Albums\",\n\t\"appName\": \"Snae Player\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"Une mise à jour est disponible\",\n\t\"artist\": \"Artiste\",\n\t\"artists\": \"Artistes\",\n\t\"cancel\": \"Annuler\",\n\t\"created\": \"Créé\",\n\t\"description\": \"Description\",\n\t\"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.\",\n\t\"dismiss\": \"Fermer\",\n\t\"duration\": \"Durée\",\n\t\"equalizerClose\": \"Fermer\",\n\t\"equalizerOpenEqualizer\": \"Ouvrir l'égaliseur\",\n\t\"equalizerPresetAcoustic\": \"Acoustique\",\n\t\"equalizerPresetBassBoost\": \"Renforcement des basses\",\n\t\"equalizerPresetClassical\": \"Classique\",\n\t\"equalizerPresetElectronic\": \"Électronique\",\n\t\"equalizerPresetFlat\": \"Plat\",\n\t\"equalizerPresetJazz\": \"Jazz\",\n\t\"equalizerPresetPop\": \"Pop\",\n\t\"equalizerPresetRock\": \"Rock\",\n\t\"equalizerPresetTrebleBoost\": \"Renforcement des aigus\",\n\t\"equalizerReset\": \"Réinitialiser\",\n\t\"equalizerStatusEnabled\": \"Activé\",\n\t\"equalizerTitle\": \"Égaliseur\",\n\t\"errorPageDoesNotExist\": \"Il semble que cette page n’existe pas.\",\n\t\"errorUnexpected\": \"Une erreur inattendue s’est produite.\",\n\t\"favorites\": \"Favoris\",\n\t\"foundAnIssue\": \"Vous avez trouvé un problème ?\",\n\t\"goBack\": \"Retour\",\n\t\"goHome\": \"Aller à l'accueil\",\n\t\"library\": \"Bibliothèque\",\n\t\"libraryAddToPlaylist\": \"Ajouter à la liste de lecture\",\n\t\"libraryApplicationMenu\": \"Menu de l’application\",\n\t\"libraryCancel\": \"Annuler\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"Êtes-vous sûr de vouloir supprimer ces {count} éléments ?\",\n\t\"libraryConfirmRemoveTitle\": \"Êtes-vous sûr de vouloir supprimer « {name} » ?\",\n\t\"libraryCreate\": \"Créer\",\n\t\"libraryCreateNewPlaylist\": \"Créer une nouvelle liste de lecture\",\n\t\"libraryDirPromptBrowserPermission\": \"Autorisation du navigateur requise\",\n\t\"libraryDirPromptExplanation\": \"Pour lire la musique, l’application a besoin d’accéder à ces dossiers :\",\n\t\"libraryDirPromptGrant\": \"Autoriser\",\n\t\"libraryEditPlaylist\": \"Modifier la liste de lecture\",\n\t\"libraryEditPlaylistName\": \"Modifier le nom de la liste de lecture\",\n\t\"libraryEmpty\": \"Votre bibliothèque est vide\",\n\t\"libraryImportTracks\": \"Importer des pistes\",\n\t\"libraryItemRemovedFromLibrary\": \"Élément supprimé de la bibliothèque\",\n\t\"libraryItemsRemovedFromLibrary\": \"Éléments supprimés de la bibliothèque\",\n\t\"libraryNewPlaylist\": \"Nouvelle liste de lecture\",\n\t\"libraryNoResults\": \"Aucun résultat trouvé\",\n\t\"libraryNoResultsExplanation\": \"Essayez de rechercher autre chose\",\n\t\"libraryOpenApplicationMenu\": \"Ouvrir le menu de l’application\",\n\t\"libraryOpenSortMenu\": \"Ouvrir le menu de tri\",\n\t\"libraryPlaylistCreated\": \"Liste de lecture « {playlistName} » créée\",\n\t\"libraryPlaylistFieldName\": \"nom de la liste de lecture\",\n\t\"libraryPlaylistName\": \"Nom de la liste de lecture\",\n\t\"libraryPlaylistRemoved\": \"Liste de lecture supprimée\",\n\t\"libraryPlaylistsUpdated\": \"Listes de lecture mises à jour\",\n\t\"libraryPlaylistUpdated\": \"Liste de lecture mise à jour\",\n\t\"libraryRemove\": \"Supprimer\",\n\t\"libraryRemoveFromLibrary\": \"Supprimer de la bibliothèque\",\n\t\"librarySave\": \"Enregistrer\",\n\t\"librarySearch\": \"Rechercher\",\n\t\"librarySelectSomethingToBeShown\": \"Sélectionnez un élément de la liste pour l’afficher ici\",\n\t\"librarySplitViewDisable\": \"Désactiver la vue partagée\",\n\t\"librarySplitViewEnable\": \"Activer la vue partagée\",\n\t\"libraryStartByAdding\": \"Commencez par ajouter de la musique\",\n\t\"libraryToggleSortOrder\": \"Changer l’ordre de tri\",\n\t\"libraryTrackRemovedFromPlaylist\": \"Piste retirée de la liste de lecture\",\n\t\"libraryTrackRemoveFromPlaylist\": \"Retirer de la liste de lecture\",\n\t\"libraryTracksCount\": \"{count} pistes\",\n\t\"libraryViewDetails\": \"Voir les détails\",\n\t\"more\": \"Plus\",\n\t\"moreOptions\": \"Plus d’options\",\n\t\"name\": \"Nom\",\n\t\"noItemsToDisplay\": \"Aucun élément à afficher\",\n\t\"pause\": \"Pause\",\n\t\"play\": \"Lecture\",\n\t\"player\": \"Lecteur\",\n\t\"playerAddToQueue\": \"Ajouter à la file d’attente\",\n\t\"playerAudioErrorLoadError\": \"Impossible de charger l’audio pour \\\"{name}\\\"\",\n\t\"playerAudioErrorNotFound\": \"Fichier audio introuvable pour \\\"{name}\\\". Il a peut-être été déplacé ou supprimé.\",\n\t\"playerAudioErrorPermissionDenied\": \"Autorisation refusée pour charger l’audio de \\\"{name}\\\". Veuillez accorder la permission du navigateur et réessayer.\",\n\t\"playerClearHistory\": \"Effacer l'historique\",\n\t\"playerClearQueue\": \"Vider la file d’attente\",\n\t\"playerDecreaseVolume\": \"Diminuer le volume\",\n\t\"playerDisableRepeat\": \"Désactiver la répétition\",\n\t\"playerDisableShuffle\": \"Désactiver la lecture aléatoire\",\n\t\"playerEnableRepeat\": \"Activer la répétition\",\n\t\"playerEnableRepeatOne\": \"Répéter une seule piste\",\n\t\"playerEnableShuffle\": \"Activer la lecture aléatoire\",\n\t\"playerHistory\": \"Historique\",\n\t\"playerHistoryEmpty\": \"Votre historique d'écoute est vide\",\n\t\"playerIncreaseVolume\": \"Augmenter le volume\",\n\t\"playerOpenFullPlayer\": \"Ouvrir le lecteur en plein écran\",\n\t\"playerOpenHistory\": \"Ouvrir l'historique\",\n\t\"playerOpenQueue\": \"Ouvrir la file d’attente\",\n\t\"playerPause\": \"Pause\",\n\t\"playerPlay\": \"Lecture\",\n\t\"playerPlayNextTrack\": \"Piste suivante\",\n\t\"playerPlayPreviousTrack\": \"Piste précédente\",\n\t\"playerQueueEmpty\": \"Votre file d’attente est vide\",\n\t\"playerQueuePlaySomething\": \"Lancez une lecture ici\",\n\t\"playerRemoveFromHistory\": \"Retirer de l'historique\",\n\t\"playerRemoveFromQueue\": \"Retirer de la file d’attente\",\n\t\"playlist\": \"Liste de lecture\",\n\t\"playlists\": \"Listes de lecture\",\n\t\"queue\": \"File d’attente\",\n\t\"reload\": \"Recharger\",\n\t\"replace\": \"Remplacer\",\n\t\"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.\",\n\t\"replaceDirectoryQ\": \"Remplacer le dossier ?\",\n\t\"selectAll\": \"Tout sélectionner\",\n\t\"selectedCount\": \"{count} sélectionnés\",\n\t\"settingPickColorFromArtwork\": \"Choisir automatiquement la couleur à partir de la pochette de la piste en cours\",\n\t\"settings\": \"Paramètres\",\n\t\"settingsAbout\": \"À propos\",\n\t\"settingsAddDirectory\": \"Ajouter un dossier\",\n\t\"settingsAllDataLocal\": \"Toutes les données sont conservées sur votre appareil\",\n\t\"settingsAppearance\": \"Apparence\",\n\t\"settingsApplicationTheme\": \"Thème de l’application\",\n\t\"settingsColorPick\": \"Choisir la couleur\",\n\t\"settingsColorReset\": \"Réinitialiser\",\n\t\"settingsDbOperationInProgress\": \"Opération de base de données en cours…\",\n\t\"settingsDirectories\": \"Dossiers\",\n\t\"settingsDirectoriesTracksCount\": \"{count} pistes\",\n\t\"settingsDirectoryRemoved\": \"Dossier supprimé\",\n\t\"settingsDirRemove\": \"Supprimer\",\n\t\"settingsDirRescan\": \"Réanalyser\",\n\t\"settingsDisplayVolumeSlider\": \"Afficher le curseur de volume dans le lecteur\",\n\t\"settingsGrantDirectoryAccess\": \"Vous devez autoriser l’appli à accéder au dossier via une permission du navigateur afin qu’elle puisse analyser son contenu\",\n\t\"settingsImportTracks\": \"Importer des pistes\",\n\t\"settingsInstallAppDesktop\": \"bureau\",\n\t\"settingsInstallAppExplanation\": \"Ajoutez Snae Player à votre {device} pour une expérience plus immersive\",\n\t\"settingsInstallAppHomeAction\": \"Installer\",\n\t\"settingsInstallAppHomeScreen\": \"écran d’accueil\",\n\t\"settingsLanguage\": \"Langue\",\n\t\"settingsMissingFs1\": \"Votre navigateur ne prend pas en charge les \",\n\t\"settingsMissingFs2\": \"fonctionnalités du système de fichiers,\",\n\t\"settingsMissingFs3\": \" nécessaires pour l’accès complet aux dossiers. L’application doit\",\n\t\"settingsMissingFs4\": \" donc copier chaque fichier musical dans son propre stockage, ce\",\n\t\"settingsMissingFs5\": \" qui peut occuper beaucoup d’espace sur votre appareil.\",\n\t\"settingsMotion\": \"Animation\",\n\t\"settingsMotionAuto\": \"Auto\",\n\t\"settingsMotionNormal\": \"Normal\",\n\t\"settingsMotionReduced\": \"Réduit\",\n\t\"settingsPlaybackSpeed\": \"Vitesse de lecture\",\n\t\"settingsPlaybackSpeedReset\": \"Réinitialiser la vitesse\",\n\t\"settingsPreparingForScan\": \"Préparation de l’analyse\",\n\t\"settingsPreservePitch\": \"Conserver la hauteur\",\n\t\"settingsPreservePitchInfo\": \"Conserve la tonalité d'origine des voix et des instruments lorsque la vitesse de lecture change.\",\n\t\"settingsPrimaryColor\": \"Couleur principale de l’application\",\n\t\"settingsScanInProgress\": \"Analyse des pistes. {current} sur {total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"{newTracks} nouvelles pistes ou pistes mises à jour trouvées\",\n\t\"settingsScanNoNewTracks\": \"Aucune nouvelle piste trouvée\",\n\t\"settingsThemeAuto\": \"Auto\",\n\t\"settingsThemeDark\": \"Sombre\",\n\t\"settingsThemeLight\": \"Clair\",\n\t\"settingsTracksInAppStorageTooltip\": \"Contient les pistes stockées dans le stockage de l'application et/ou les données migrées depuis Snae Player v1\",\n\t\"settingsTracksInsideAppMemory\": \"Pistes dans le stockage de l’application\",\n\t\"shuffle\": \"Aléatoire\",\n\t\"successfullyRemovedTracks\": \"{count} pistes supprimées avec succès\",\n\t\"track\": \"Piste\",\n\t\"trackAddToFavorites\": \"Ajouter aux favoris\",\n\t\"trackPlay\": \"Lire {name}\",\n\t\"trackRemoveFromFavorites\": \"Retirer des favoris\",\n\t\"tracks\": \"Pistes\",\n\t\"trackViewAlbum\": \"Voir l’album\",\n\t\"trackViewArtist\": \"Voir l’artiste\",\n\t\"understood\": \"Compris\",\n\t\"unknown\": \"Inconnu\",\n\t\"validationMaxLength\": \"{max} caractères maximum autorisés\",\n\t\"validationMinLength\": \"{min} caractères minimum requis\",\n\t\"validationRequired\": \"Champ obligatoire\",\n\t\"year\": \"Année\"\n}\n"
  },
  {
    "path": "messages/lt.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"Apie\",\n\t\"aboutHomepage\": \"Namai\",\n\t\"aboutJoinDiscord\": \"Discord\",\n\t\"aboutPrivacy\": \"Privatumas\",\n\t\"aboutSourceCode\": \"Programos kodas\",\n\n\t\"album\": \"Albumas\",\n\t\"albums\": \"Albumai\",\n\n\t\"appName\": \"Snae grotuvas\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"Galimas programos atnaujinimas\",\n\n\t\"artist\": \"Atlikėjas\",\n\t\"artists\": \"Atlikėjai\",\n\t\"cancel\": \"Atšaukti\",\n\t\"created\": \"Sukurta\",\n\t\"description\": \"Aprašymas\",\n\t\"directoryIsIncludedInParent\": \"\\\"{newDir}\\\" yra \\\"{existingDir}\\\" poaplankis, kuris jau yra jūsų bibliotekoje. Jums nereikia jo pridėti dar kartą.\",\n\t\"dismiss\": \"Uždaryti\",\n\t\"duration\": \"Trukmė\",\n\t\"equalizerClose\": \"Uždaryti\",\n\t\"equalizerOpenEqualizer\": \"Atidaryti ekvalaizerį\",\n\t\"equalizerPresetAcoustic\": \"Akustinis\",\n\t\"equalizerPresetBassBoost\": \"Paryškinti žemus dažnius\",\n\t\"equalizerPresetClassical\": \"Klasikinis\",\n\t\"equalizerPresetElectronic\": \"Elektroninis\",\n\t\"equalizerPresetFlat\": \"Neutralus\",\n\t\"equalizerPresetJazz\": \"Džiazas\",\n\t\"equalizerPresetPop\": \"Pop\",\n\t\"equalizerPresetRock\": \"Rokas\",\n\t\"equalizerPresetTrebleBoost\": \"Paryškinti aukštus dažnius\",\n\t\"equalizerReset\": \"Atstatyti\",\n\t\"equalizerStatusEnabled\": \"Įjungtas\",\n\t\"equalizerTitle\": \"Ekvalaizeris\",\n\n\t\"errorPageDoesNotExist\": \"Panašu, kad šio puslapio nėra.\",\n\t\"errorUnexpected\": \"Įvyko netikėta klaida.\",\n\t\"favorites\": \"Mėgstamiausi\",\n\t\"foundAnIssue\": \"Radote klaidą?\",\n\t\"goBack\": \"Grįžti\",\n\t\"goHome\": \"Grįžti į pradžią\",\n\t\"library\": \"Biblioteka\",\n\t\"libraryAddToPlaylist\": \"Pridėti į grojaraštį\",\n\t\"libraryApplicationMenu\": \"Programos meniu\",\n\t\"libraryCancel\": \"Atšaukti\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"Ar tikrai norite pašalinti šiuos {count} elementus?\",\n\t\"libraryConfirmRemoveTitle\": \"Ar tikrai norite pašalinti „{name}“?\",\n\t\"libraryCreate\": \"Sukurti\",\n\t\"libraryCreateNewPlaylist\": \"Sukurti naują grojaraštį\",\n\t\"libraryDirPromptBrowserPermission\": \"Reikalingas naršyklės leidimas\",\n\t\"libraryDirPromptExplanation\": \"Kad galėtumėte klausytis muzikos, programai reikia leidimo pasiekti šiuos katalogus:\",\n\t\"libraryDirPromptGrant\": \"Suteikti\",\n\n\t\"libraryEditPlaylist\": \"Redaguoti grojaraštį\",\n\t\"libraryEditPlaylistName\": \"Redaguoti grojaraščio pavadinimą\",\n\t\"libraryEmpty\": \"Jūsų biblioteka tuščia\",\n\t\"libraryImportTracks\": \"Importuoti kūrinius\",\n\t\"libraryItemRemovedFromLibrary\": \"Elementas pašalintas iš bibliotekos\",\n\t\"libraryItemsRemovedFromLibrary\": \"Elementai pašalinti iš bibliotekos\",\n\t\"libraryNewPlaylist\": \"Naujas grojaraštis\",\n\n\t\"libraryNoResults\": \"Rezultatų nerasta\",\n\t\"libraryNoResultsExplanation\": \"Pabandykite ieškoti kitaip\",\n\t\"libraryOpenApplicationMenu\": \"Atidaryti programos meniu\",\n\t\"libraryOpenSortMenu\": \"Atidaryti rūšiavimo meniu\",\n\t\"libraryPlaylistCreated\": \"Grojaraštis „{playlistName}“ sukurtas\",\n\t\"libraryPlaylistFieldName\": \"grojaraščio pavadinimas\",\n\t\"libraryPlaylistName\": \"Grojaraščio pavadinimas\",\n\t\"libraryPlaylistRemoved\": \"Grojaraštis pašalintas\",\n\t\"libraryPlaylistsUpdated\": \"Grojaraščiai atnaujinti\",\n\t\"libraryPlaylistUpdated\": \"Grojaraštis atnaujintas\",\n\t\"libraryRemove\": \"Pašalinti\",\n\t\"libraryRemoveFromLibrary\": \"Pašalinti iš bibliotekos\",\n\t\"librarySave\": \"Išsaugoti\",\n\n\t\"librarySearch\": \"Paieška\",\n\t\"librarySelectSomethingToBeShown\": \"Pasirinkite ką nors iš sąrašo, kad būtų rodoma čia\",\n\t\"librarySplitViewDisable\": \"Išjungti padalintą išdėstymą\",\n\t\"librarySplitViewEnable\": \"Įjungti padalintą išdėstymą\",\n\t\"libraryStartByAdding\": \"Pradėkite pridėdami muzikos\",\n\t\"libraryToggleSortOrder\": \"Perjungti rūšiavimo tvarką\",\n\t\"libraryTrackRemovedFromPlaylist\": \"Kūrinys pašalintas iš grojaraščio\",\n\n\t\"libraryTrackRemoveFromPlaylist\": \"Pašalinti iš grojaraščio\",\n\t\"libraryTracksCount\": \"{count} kūriniai\",\n\t\"libraryViewDetails\": \"Peržiūrėti detales\",\n\t\"more\": \"Daugiau\",\n\t\"moreOptions\": \"Daugiau parinkčių\",\n\t\"name\": \"Pavadinimas\",\n\t\"noItemsToDisplay\": \"Nėra elementų rodymui\",\n\t\"pause\": \"Pristabdyti\",\n\t\"play\": \"Groti\",\n\n\t\"player\": \"Grotuvas\",\n\t\"playerAddToQueue\": \"Pridėti į eilę\",\n\t\"playerAudioErrorLoadError\": \"Nepavyko įkelti garso įrašo \\\"{name}\\\"\",\n\t\"playerAudioErrorNotFound\": \"Garso failas \\\"{name}\\\" nerastas. Gali būti perkeltas arba ištrintas.\",\n\t\"playerAudioErrorPermissionDenied\": \"Trūksta leidimo įkelti garso įrašą \\\"{name}\\\". Suteikite naršyklės leidimą ir bandykite dar kartą.\",\n\t\"playerClearHistory\": \"Išvalyti istoriją\",\n\t\"playerClearQueue\": \"Išvalyti eilę\",\n\t\"playerDecreaseVolume\": \"Sumažinti garsumą\",\n\t\"playerDisableRepeat\": \"Išjungti kartojimą\",\n\t\"playerDisableShuffle\": \"Išjungti maišymą\",\n\t\"playerEnableRepeat\": \"Įjungti kartojimą\",\n\t\"playerEnableRepeatOne\": \"Įjungti vieno kūrinio kartojimą\",\n\t\"playerEnableShuffle\": \"Įjungti maišymą\",\n\t\"playerHistory\": \"Istorija\",\n\t\"playerHistoryEmpty\": \"Jūsų klausymo istorija tuščia\",\n\t\"playerIncreaseVolume\": \"Padidinti garsumą\",\n\t\"playerOpenFullPlayer\": \"Atidaryti pilną grotuvą\",\n\t\"playerOpenHistory\": \"Atidaryti istoriją\",\n\t\"playerOpenQueue\": \"Atidaryti eilę\",\n\t\"playerPause\": \"Pristabdyti\",\n\t\"playerPlay\": \"Groti\",\n\t\"playerPlayNextTrack\": \"Groti kitą kūrinį\",\n\t\"playerPlayPreviousTrack\": \"Groti ankstesnį kūrinį\",\n\t\"playerQueueEmpty\": \"Jūsų eilė tuščia\",\n\t\"playerQueuePlaySomething\": \"Grokite ką nors čia\",\n\t\"playerRemoveFromHistory\": \"Pašalinti iš istorijos\",\n\t\"playerRemoveFromQueue\": \"Pašalinti iš eilės\",\n\t\"playlist\": \"Grojaraštis\",\n\t\"playlists\": \"Grojaraščiai\",\n\t\"queue\": \"Eilė\",\n\t\"reload\": \"Įkelti iš naujo\",\n\t\"replace\": \"Pakeisti\",\n\t\"replaceDirectoryExplanation\": \"Katalogas {newDir}, kurį pridedate, yra {existingDirs} pirminis katalogas, kuris jau egzistuoja jūsų bibliotekoje.\\n Esamiems kūriniams bibliotekoje niekas nepasikeis.\",\n\n\t\"replaceDirectoryQ\": \"Pakeisti katalogą?\",\n\t\"selectAll\": \"Pasirinkti viską\",\n\t\"selectedCount\": \"Pasirinkta: {count}\",\n\t\"settingPickColorFromArtwork\": \"Automatiškai parinkti spalvą pagal šiuo metu grojančio kūrinio viršelį\",\n\t\"settings\": \"Nustatymai\",\n\t\"settingsAbout\": \"Apie\",\n\n\t\"settingsAddDirectory\": \"Pridėti katalogą\",\n\n\t\"settingsAllDataLocal\": \"Visi duomenys išsaugomi jūsų įrenginyje\",\n\t\"settingsAppearance\": \"Išvaizda\",\n\t\"settingsApplicationTheme\": \"Programos tema\",\n\t\"settingsColorPick\": \"Pasirinkti spalvą\",\n\t\"settingsColorReset\": \"Atstatyti\",\n\t\"settingsDbOperationInProgress\": \"Vykdoma duomenų bazės operacija...\",\n\t\"settingsDirectories\": \"Katalogai\",\n\t\"settingsDirectoriesTracksCount\": \"{count} kūriniai\",\n\t\"settingsDirectoryRemoved\": \"Katalogas pašalintas\",\n\t\"settingsDirRemove\": \"Pašalinti\",\n\t\"settingsDirRescan\": \"Skenuoti iš naujo\",\n\t\"settingsDisplayVolumeSlider\": \"Rodyti garso slankiklį grotuve\",\n\t\"settingsGrantDirectoryAccess\": \"Reikia suteikti programai prieigą prie katalogo per naršyklės leidimą, kad ji galėtų nuskaityti jo turinį\",\n\t\"settingsImportTracks\": \"Importuoti kūrinius\",\n\t\"settingsInstallAppDesktop\": \"kompiuterį\",\n\t\"settingsInstallAppExplanation\": \"Pridėkite Snae grotuvą prie savo {device} patogesnei patirčiai\",\n\t\"settingsInstallAppHomeAction\": \"Įdiegti\",\n\t\"settingsInstallAppHomeScreen\": \"pagrindinis ekranas\",\n\t\"settingsLanguage\": \"Kalba\",\n\t\"settingsMissingFs1\": \"Jūsų naršyklė nepalaiko reikalingų \",\n\t\"settingsMissingFs2\": \"Failų sistemos funkcijų,\",\n\t\"settingsMissingFs3\": \" kad būtų galima visiškai pasiekti katalogus, todėl šiai programai veikti,\",\n\t\"settingsMissingFs4\": \"kiekvieną muzikos failą reikia nukopijuoti ir išsaugoti programos saugykloje,\",\n\t\"settingsMissingFs5\": \" tai gali užimti daug vietos jūsų įrenginyje.\",\n\t\"settingsMotion\": \"Animacijos\",\n\t\"settingsMotionAuto\": \"Automatinės\",\n\t\"settingsMotionNormal\": \"Normalios\",\n\t\"settingsMotionReduced\": \"Sumažintos\",\n\t\"settingsPlaybackSpeed\": \"Atkūrimo greitis\",\n\t\"settingsPlaybackSpeedReset\": \"Atstatyti greitį\",\n\t\"settingsPreparingForScan\": \"Ruošiamasi skenavimui\",\n\t\"settingsPreservePitch\": \"Išlaikyti toną\",\n\t\"settingsPreservePitchInfo\": \"Keičiant atkūrimo greitį, balsų ir instrumentų tonas išlieka originalus.\",\n\t\"settingsPrimaryColor\": \"Pagrindinė programos spalva\",\n\t\"settingsScanInProgress\": \"Skenuojami kūriniai. {current} iš {total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"Rasta {newTracks} naujų arba atnaujintų kūrinių\",\n\t\"settingsScanNoNewTracks\": \"Naujų kūrinių nerasta\",\n\t\"settingsThemeAuto\": \"Automatinė\",\n\t\"settingsThemeDark\": \"Tamsi\",\n\t\"settingsThemeLight\": \"Šviesi\",\n\t\"settingsTracksInAppStorageTooltip\": \"Tai apima kūrinius, saugomus programos saugykloje ir/arba duomenis, perkeltus iš Snae Player v1\",\n\t\"settingsTracksInsideAppMemory\": \"Kūriniai programos saugykloje\",\n\n\t\"shuffle\": \"Maišyti\",\n\t\"successfullyRemovedTracks\": \"Sėkmingai pašalinta {count} kūrinių\",\n\t\"track\": \"Kūrinys\",\n\t\"trackAddToFavorites\": \"Pridėti prie mėgstamiausių\",\n\n\t\"trackPlay\": \"Groti {name}\",\n\t\"trackRemoveFromFavorites\": \"Pašalinti iš mėgstamiausių\",\n\t\"tracks\": \"Kūriniai\",\n\t\"trackViewAlbum\": \"Peržiūrėti albumą\",\n\t\"trackViewArtist\": \"Peržiūrėti atlikėją\",\n\t\"understood\": \"Supratau\",\n\t\"unknown\": \"Nežinoma\",\n\t\"validationMaxLength\": \"Leidžiama daugiausiai {max} simbolių\",\n\t\"validationMinLength\": \"Reikalinga bent {min} simbolių\",\n\n\t\"validationRequired\": \"Laukas yra privalomas\",\n\t\"year\": \"Metai\"\n}\n"
  },
  {
    "path": "messages/zh-CN.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"关于\",\n\t\"aboutHomepage\": \"主页\",\n\t\"aboutJoinDiscord\": \"加入我们的 Discord\",\n\t\"aboutPrivacy\": \"隐私\",\n\t\"aboutSourceCode\": \"源代码\",\n\n\t\"album\": \"专辑\",\n\t\"albums\": \"专辑\",\n\n\t\"appName\": \"Snae 播放器\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"应用有可用更新\",\n\n\t\"artist\": \"艺术家\",\n\t\"artists\": \"艺术家\",\n\t\"cancel\": \"取消\",\n\t\"created\": \"创建时间\",\n\t\"description\": \"描述\",\n\t\"directoryIsIncludedInParent\": \"\\\"{newDir}\\\" 是 \\\"{existingDir}\\\" 的子目录，而 \\\"{existingDir}\\\" 已经在您的媒体库中。您无需再次添加。\",\n\t\"dismiss\": \"忽略\",\n\t\"duration\": \"时长\",\n\t\"equalizerClose\": \"关闭\",\n\t\"equalizerOpenEqualizer\": \"打开均衡器\",\n\t\"equalizerPresetAcoustic\": \"原声\",\n\t\"equalizerPresetBassBoost\": \"低音增强\",\n\t\"equalizerPresetClassical\": \"古典\",\n\t\"equalizerPresetElectronic\": \"电子\",\n\t\"equalizerPresetFlat\": \"平直\",\n\t\"equalizerPresetJazz\": \"爵士\",\n\t\"equalizerPresetPop\": \"流行\",\n\t\"equalizerPresetRock\": \"摇滚\",\n\t\"equalizerPresetTrebleBoost\": \"高音增强\",\n\t\"equalizerReset\": \"重置\",\n\t\"equalizerStatusEnabled\": \"已启用\",\n\t\"equalizerTitle\": \"均衡器\",\n\n\t\"errorPageDoesNotExist\": \"看起来此页面不存在。\",\n\t\"errorUnexpected\": \"发生了一个意外错误。\",\n\t\"favorites\": \"收藏夹\",\n\t\"foundAnIssue\": \"发现问题？\",\n\t\"goBack\": \"返回\",\n\t\"goHome\": \"回到首页\",\n\t\"library\": \"媒体库\",\n\t\"libraryAddToPlaylist\": \"添加到播放列表\",\n\t\"libraryApplicationMenu\": \"应用菜单\",\n\t\"libraryCancel\": \"取消\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"您确定要移除这 {count} 项吗？\",\n\t\"libraryConfirmRemoveTitle\": \"您确定要移除 \\\"{name}\\\" 吗？\",\n\t\"libraryCreate\": \"创建\",\n\t\"libraryCreateNewPlaylist\": \"创建新播放列表\",\n\t\"libraryDirPromptBrowserPermission\": \"需要浏览器权限\",\n\t\"libraryDirPromptExplanation\": \"要播放音乐，应用需要访问这些目录的权限：\",\n\t\"libraryDirPromptGrant\": \"授权\",\n\n\t\"libraryEditPlaylist\": \"编辑播放列表\",\n\t\"libraryEditPlaylistName\": \"编辑播放列表名称\",\n\t\"libraryEmpty\": \"您的媒体库是空的\",\n\t\"libraryImportTracks\": \"导入曲目\",\n\t\"libraryItemRemovedFromLibrary\": \"项目已从媒体库中移除\",\n\t\"libraryItemsRemovedFromLibrary\": \"项目已从媒体库中移除\",\n\t\"libraryNewPlaylist\": \"新播放列表\",\n\n\t\"libraryNoResults\": \"未找到结果\",\n\t\"libraryNoResultsExplanation\": \"尝试搜索其他内容\",\n\t\"libraryOpenApplicationMenu\": \"打开应用菜单\",\n\t\"libraryOpenSortMenu\": \"打开排序菜单\",\n\t\"libraryPlaylistCreated\": \"已创建播放列表 \\\"{playlistName}\\\"\",\n\t\"libraryPlaylistFieldName\": \"播放列表名称\",\n\t\"libraryPlaylistName\": \"播放列表名称\",\n\t\"libraryPlaylistRemoved\": \"播放列表已移除\",\n\t\"libraryPlaylistsUpdated\": \"播放列表已更新\",\n\t\"libraryPlaylistUpdated\": \"播放列表已更新\",\n\t\"libraryRemove\": \"移除\",\n\t\"libraryRemoveFromLibrary\": \"从媒体库中移除\",\n\t\"librarySave\": \"保存\",\n\n\t\"librarySearch\": \"搜索\",\n\t\"librarySelectSomethingToBeShown\": \"从列表中选择要在此处显示的内容\",\n\t\"librarySplitViewDisable\": \"禁用分屏视图布局\",\n\t\"librarySplitViewEnable\": \"启用分屏视图布局\",\n\t\"libraryStartByAdding\": \"首先添加一些音乐\",\n\t\"libraryToggleSortOrder\": \"切换排序方式\",\n\t\"libraryTrackRemovedFromPlaylist\": \"曲目已从播放列表中移除\",\n\n\t\"libraryTrackRemoveFromPlaylist\": \"从播放列表中移除\",\n\t\"libraryTracksCount\": \"{count} 首曲目\",\n\t\"libraryViewDetails\": \"查看详情\",\n\t\"more\": \"更多\",\n\t\"moreOptions\": \"更多选项\",\n\t\"name\": \"名称\",\n\t\"noItemsToDisplay\": \"没有要显示的项目\",\n\t\"pause\": \"暂停\",\n\t\"play\": \"播放\",\n\n\t\"player\": \"播放器\",\n\t\"playerAddToQueue\": \"添加到队列\",\n\t\"playerAudioErrorLoadError\": \"无法加载\\\"{name}\\\"的音频\",\n\t\"playerAudioErrorNotFound\": \"未找到\\\"{name}\\\"的音频文件。该文件可能已被移动或删除。\",\n\t\"playerAudioErrorPermissionDenied\": \"没有权限加载\\\"{name}\\\"的音频。请授予浏览器权限后重试。\",\n\t\"playerClearHistory\": \"清除历史记录\",\n\t\"playerClearQueue\": \"清空队列\",\n\t\"playerDecreaseVolume\": \"降低音量\",\n\t\"playerDisableRepeat\": \"禁用重复播放\",\n\t\"playerDisableShuffle\": \"禁用随机播放\",\n\t\"playerEnableRepeat\": \"启用重复播放\",\n\t\"playerEnableRepeatOne\": \"启用单曲循环\",\n\t\"playerEnableShuffle\": \"启用随机播放\",\n\t\"playerHistory\": \"历史记录\",\n\t\"playerHistoryEmpty\": \"您还没有播放任何内容\",\n\t\"playerIncreaseVolume\": \"增加音量\",\n\t\"playerOpenFullPlayer\": \"打开完整播放器\",\n\t\"playerOpenHistory\": \"打开历史记录\",\n\t\"playerOpenQueue\": \"打开队列\",\n\t\"playerPause\": \"暂停\",\n\t\"playerPlay\": \"播放\",\n\t\"playerPlayNextTrack\": \"播放下一首曲目\",\n\t\"playerPlayPreviousTrack\": \"播放上一首曲目\",\n\t\"playerQueueEmpty\": \"您的队列是空的\",\n\t\"playerQueuePlaySomething\": \"在此播放一些内容\",\n\t\"playerRemoveFromHistory\": \"从历史记录中移除\",\n\t\"playerRemoveFromQueue\": \"从队列中移除\",\n\t\"playlist\": \"播放列表\",\n\t\"playlists\": \"播放列表\",\n\t\"queue\": \"队列\",\n\t\"reload\": \"重新加载\",\n\t\"replace\": \"替换\",\n\t\"replaceDirectoryExplanation\": \"您要添加的目录 {newDir} 是 {existingDirs} 的父目录，而 {existingDirs} 已经存在于您的媒体库中。\\n 对您媒体库中现有的曲目不会进行任何更改。\",\n\n\t\"replaceDirectoryQ\": \"替换目录？\",\n\t\"selectAll\": \"全选\",\n\t\"selectedCount\": \"已选择 {count}\",\n\t\"settingPickColorFromArtwork\": \"自动从当前播放歌曲的专辑封面中选取颜色\",\n\t\"settings\": \"设置\",\n\t\"settingsAbout\": \"关于\",\n\n\t\"settingsAddDirectory\": \"添加目录\",\n\n\t\"settingsAllDataLocal\": \"所有数据都保存在您的设备上\",\n\t\"settingsAppearance\": \"外观\",\n\t\"settingsApplicationTheme\": \"应用主题\",\n\t\"settingsColorPick\": \"选取颜色\",\n\t\"settingsColorReset\": \"重置\",\n\t\"settingsDbOperationInProgress\": \"数据库操作正在进行中...\",\n\t\"settingsDirectories\": \"目录\",\n\t\"settingsDirectoriesTracksCount\": \"{count} 首曲目\",\n\t\"settingsDirectoryRemoved\": \"目录已移除\",\n\t\"settingsDirRemove\": \"移除\",\n\t\"settingsDirRescan\": \"重新扫描\",\n\t\"settingsDisplayVolumeSlider\": \"在播放器内显示音量滑块\",\n\t\"settingsGrantDirectoryAccess\": \"您需要允许应用访问该目录，通过浏览器权限，以便它可以扫描其内容\",\n\t\"settingsImportTracks\": \"导入曲目\",\n\t\"settingsInstallAppDesktop\": \"桌面版\",\n\t\"settingsInstallAppExplanation\": \"将 Snae 播放器添加到您的 {device} 以获得更沉浸式的体验\",\n\t\"settingsInstallAppHomeAction\": \"安装\",\n\t\"settingsInstallAppHomeScreen\": \"主屏幕\",\n\t\"settingsLanguage\": \"语言\",\n\t\"settingsMissingFs1\": \"您的浏览器不支持所需的 \",\n\t\"settingsMissingFs2\": \"文件系统功能，\",\n\t\"settingsMissingFs3\": \" 为了使此应用正常工作，\",\n\t\"settingsMissingFs4\": \"每个音乐文件都必须复制并保存在应用存储中，\",\n\t\"settingsMissingFs5\": \" 这可能会占用您设备的大量磁盘空间。\",\n\t\"settingsMotion\": \"动画\",\n\t\"settingsMotionAuto\": \"自动\",\n\t\"settingsMotionNormal\": \"正常\",\n\t\"settingsMotionReduced\": \"减少\",\n\t\"settingsPlaybackSpeed\": \"播放速度\",\n\t\"settingsPlaybackSpeedReset\": \"重置速度\",\n\t\"settingsPreparingForScan\": \"准备扫描\",\n\t\"settingsPreservePitch\": \"保持音高\",\n\t\"settingsPreservePitchInfo\": \"在更改播放速度时，保持人声和乐器的原始音高不变。\",\n\t\"settingsPrimaryColor\": \"应用主色\",\n\t\"settingsScanInProgress\": \"正在扫描曲目。{current}/{total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"找到 {newTracks} 首新的或更新的曲目\",\n\t\"settingsScanNoNewTracks\": \"未找到新的曲目\",\n\t\"settingsThemeAuto\": \"自动\",\n\t\"settingsThemeDark\": \"深色\",\n\t\"settingsThemeLight\": \"浅色\",\n\t\"settingsTracksInAppStorageTooltip\": \"这包含存储在应用存储中的曲目和/或您从 Snae Player v1 迁移的数据\",\n\t\"settingsTracksInsideAppMemory\": \"应用存储中的曲目\",\n\n\t\"shuffle\": \"随机播放\",\n\t\"successfullyRemovedTracks\": \"成功移除 {count} 首曲目\",\n\t\"track\": \"曲目\",\n\t\"trackAddToFavorites\": \"添加到收藏夹\",\n\n\t\"trackPlay\": \"播放 {name}\",\n\t\"trackRemoveFromFavorites\": \"从收藏夹中移除\",\n\t\"tracks\": \"曲目\",\n\t\"trackViewAlbum\": \"查看专辑\",\n\t\"trackViewArtist\": \"查看艺术家\",\n\t\"understood\": \"明白\",\n\t\"unknown\": \"未知\",\n\t\"validationMaxLength\": \"最多允许 {max} 个字符\",\n\t\"validationMinLength\": \"至少需要 {min} 个字符\",\n\n\t\"validationRequired\": \"必填字段\",\n\t\"year\": \"年份\"\n}\n"
  },
  {
    "path": "messages/zh-TW.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"關於\",\n\t\"aboutHomepage\": \"首頁\",\n\t\"aboutJoinDiscord\": \"加入我們的 Discord\",\n\t\"aboutPrivacy\": \"隱私\",\n\t\"aboutSourceCode\": \"原始碼\",\n\n\t\"album\": \"專輯\",\n\t\"albums\": \"專輯\",\n\n\t\"appName\": \"Snae 播放器\",\n\t\"appNameShort\": \"Snae\",\n\t\"appUpdateAvailable\": \"應用程式有可用更新\",\n\n\t\"artist\": \"藝術家\",\n\t\"artists\": \"藝術家\",\n\t\"cancel\": \"取消\",\n\t\"created\": \"建立時間\",\n\t\"description\": \"描述\",\n\t\"directoryIsIncludedInParent\": \"「{newDir}」是「{existingDir}」的子目錄，而「{existingDir}」已經在您的媒體庫中。您無需再次新增。\",\n\t\"dismiss\": \"忽略\",\n\t\"duration\": \"時長\",\n\t\"equalizerClose\": \"關閉\",\n\t\"equalizerOpenEqualizer\": \"開啟等化器\",\n\t\"equalizerPresetAcoustic\": \"原聲\",\n\t\"equalizerPresetBassBoost\": \"低音增強\",\n\t\"equalizerPresetClassical\": \"古典\",\n\t\"equalizerPresetElectronic\": \"電子\",\n\t\"equalizerPresetFlat\": \"平直\",\n\t\"equalizerPresetJazz\": \"爵士\",\n\t\"equalizerPresetPop\": \"流行\",\n\t\"equalizerPresetRock\": \"搖滾\",\n\t\"equalizerPresetTrebleBoost\": \"高音增強\",\n\t\"equalizerReset\": \"重設\",\n\t\"equalizerStatusEnabled\": \"已啟用\",\n\t\"equalizerTitle\": \"等化器\",\n\n\t\"errorPageDoesNotExist\": \"看起來此頁面不存在。\",\n\t\"errorUnexpected\": \"發生了一個意外錯誤。\",\n\t\"favorites\": \"收藏夾\",\n\t\"foundAnIssue\": \"發現問題？\",\n\t\"goBack\": \"返回\",\n\t\"goHome\": \"回到首頁\",\n\t\"library\": \"媒體庫\",\n\t\"libraryAddToPlaylist\": \"新增到播放列表\",\n\t\"libraryApplicationMenu\": \"應用程式選單\",\n\t\"libraryCancel\": \"取消\",\n\t\"libraryConfirmRemoveMultipleTitle\": \"您確定要移除這 {count} 項嗎？\",\n\t\"libraryConfirmRemoveTitle\": \"您確定要移除「{name}」嗎？\",\n\t\"libraryCreate\": \"建立\",\n\t\"libraryCreateNewPlaylist\": \"建立新播放列表\",\n\t\"libraryDirPromptBrowserPermission\": \"需要瀏覽器權限\",\n\t\"libraryDirPromptExplanation\": \"要播放音樂，應用程式需要存取這些目錄的權限：\",\n\t\"libraryDirPromptGrant\": \"授權\",\n\n\t\"libraryEditPlaylist\": \"編輯播放列表\",\n\t\"libraryEditPlaylistName\": \"編輯播放列表名稱\",\n\t\"libraryEmpty\": \"您的媒體庫是空的\",\n\t\"libraryImportTracks\": \"匯入曲目\",\n\t\"libraryItemRemovedFromLibrary\": \"項目已從媒體庫中移除\",\n\t\"libraryItemsRemovedFromLibrary\": \"項目已從媒體庫中移除\",\n\t\"libraryNewPlaylist\": \"新播放列表\",\n\n\t\"libraryNoResults\": \"未找到結果\",\n\t\"libraryNoResultsExplanation\": \"嘗試搜尋其他內容\",\n\t\"libraryOpenApplicationMenu\": \"開啟應用程式選單\",\n\t\"libraryOpenSortMenu\": \"開啟排序選單\",\n\t\"libraryPlaylistCreated\": \"已建立播放列表「{playlistName}」\",\n\t\"libraryPlaylistFieldName\": \"播放列表名稱\",\n\t\"libraryPlaylistName\": \"播放列表名稱\",\n\t\"libraryPlaylistRemoved\": \"播放列表已移除\",\n\t\"libraryPlaylistsUpdated\": \"播放列表已更新\",\n\t\"libraryPlaylistUpdated\": \"播放列表已更新\",\n\t\"libraryRemove\": \"移除\",\n\t\"libraryRemoveFromLibrary\": \"從媒體庫中移除\",\n\t\"librarySave\": \"儲存\",\n\n\t\"librarySearch\": \"搜尋\",\n\t\"librarySelectSomethingToBeShown\": \"從列表中選擇要在此處顯示的內容\",\n\t\"librarySplitViewDisable\": \"停用分屏檢視佈局\",\n\t\"librarySplitViewEnable\": \"啟用分屏檢視佈局\",\n\t\"libraryStartByAdding\": \"首先新增一些音樂\",\n\t\"libraryToggleSortOrder\": \"切換排序方式\",\n\t\"libraryTrackRemovedFromPlaylist\": \"曲目已從播放列表中移除\",\n\n\t\"libraryTrackRemoveFromPlaylist\": \"從播放列表中移除\",\n\t\"libraryTracksCount\": \"{count} 首曲目\",\n\t\"libraryViewDetails\": \"檢視詳細資訊\",\n\t\"more\": \"更多\",\n\t\"moreOptions\": \"更多選項\",\n\t\"name\": \"名稱\",\n\t\"noItemsToDisplay\": \"沒有要顯示的項目\",\n\t\"pause\": \"暫停\",\n\t\"play\": \"播放\",\n\n\t\"player\": \"播放器\",\n\t\"playerAddToQueue\": \"新增到佇列\",\n\t\"playerAudioErrorLoadError\": \"無法載入\\\"{name}\\\"的音訊\",\n\t\"playerAudioErrorNotFound\": \"找不到\\\"{name}\\\"的音訊檔案。該檔案可能已被移動或刪除。\",\n\t\"playerAudioErrorPermissionDenied\": \"沒有權限載入\\\"{name}\\\"的音訊。請授予瀏覽器權限後再試一次。\",\n\t\"playerClearHistory\": \"清除歷史記錄\",\n\t\"playerClearQueue\": \"清空佇列\",\n\t\"playerDecreaseVolume\": \"降低音量\",\n\t\"playerDisableRepeat\": \"停用重複播放\",\n\t\"playerDisableShuffle\": \"停用隨機播放\",\n\t\"playerEnableRepeat\": \"啟用重複播放\",\n\t\"playerEnableRepeatOne\": \"啟用單曲循環\",\n\t\"playerEnableShuffle\": \"啟用隨機播放\",\n\t\"playerHistory\": \"歷史記錄\",\n\t\"playerHistoryEmpty\": \"您的播放歷史記錄為空\",\n\t\"playerIncreaseVolume\": \"增加音量\",\n\t\"playerOpenFullPlayer\": \"開啟完整播放器\",\n\t\"playerOpenHistory\": \"開啟歷史記錄\",\n\t\"playerOpenQueue\": \"開啟佇列\",\n\t\"playerPause\": \"暫停\",\n\t\"playerPlay\": \"播放\",\n\t\"playerPlayNextTrack\": \"播放下一首曲目\",\n\t\"playerPlayPreviousTrack\": \"播放上一首曲目\",\n\t\"playerQueueEmpty\": \"您的佇列是空的\",\n\t\"playerQueuePlaySomething\": \"在此播放一些內容\",\n\t\"playerRemoveFromHistory\": \"從歷史記錄中移除\",\n\t\"playerRemoveFromQueue\": \"從佇列中移除\",\n\t\"playlist\": \"播放列表\",\n\t\"playlists\": \"播放列表\",\n\t\"queue\": \"佇列\",\n\t\"reload\": \"重新載入\",\n\t\"replace\": \"取代\",\n\t\"replaceDirectoryExplanation\": \"您要新增的目錄 {newDir} 是 {existingDirs} 的父目錄，而 {existingDirs} 已經存在於您的媒體庫中。\\n 對您媒體庫中現有的曲目不會進行任何變更。\",\n\n\t\"replaceDirectoryQ\": \"取代目錄？\",\n\t\"selectAll\": \"全選\",\n\t\"selectedCount\": \"已選取 {count}\",\n\t\"settingPickColorFromArtwork\": \"自動從目前播放歌曲的專輯封面中選取顏色\",\n\t\"settings\": \"設定\",\n\t\"settingsAbout\": \"關於\",\n\n\t\"settingsAddDirectory\": \"新增目錄\",\n\n\t\"settingsAllDataLocal\": \"所有資料都保存在您的裝置上\",\n\t\"settingsAppearance\": \"外觀\",\n\t\"settingsApplicationTheme\": \"應用程式主題\",\n\t\"settingsColorPick\": \"選取顏色\",\n\t\"settingsColorReset\": \"重設\",\n\t\"settingsDbOperationInProgress\": \"資料庫操作正在進行中...\",\n\t\"settingsDirectories\": \"目錄\",\n\t\"settingsDirectoriesTracksCount\": \"{count} 首曲目\",\n\t\"settingsDirectoryRemoved\": \"目錄已移除\",\n\t\"settingsDirRemove\": \"移除\",\n\t\"settingsDirRescan\": \"重新掃描\",\n\t\"settingsDisplayVolumeSlider\": \"在播放器內顯示音量滑桿\",\n\t\"settingsGrantDirectoryAccess\": \"您需要允許應用程式存取該目錄，透過瀏覽器權限，以便它可以掃描其內容\",\n\t\"settingsImportTracks\": \"匯入曲目\",\n\t\"settingsInstallAppDesktop\": \"桌面版\",\n\t\"settingsInstallAppExplanation\": \"將 Snae 播放器新增到您的 {device} 以獲得更沉浸式的體驗\",\n\t\"settingsInstallAppHomeAction\": \"安裝\",\n\t\"settingsInstallAppHomeScreen\": \"主畫面\",\n\t\"settingsLanguage\": \"語言\",\n\t\"settingsMissingFs1\": \"您的瀏覽器不支援所需的 \",\n\t\"settingsMissingFs2\": \"檔案系統功能，\",\n\t\"settingsMissingFs3\": \" 為了使此應用程式正常工作，\",\n\t\"settingsMissingFs4\": \"每個音樂檔案都必須複製並保存在應用程式儲存空間中，\",\n\t\"settingsMissingFs5\": \" 這可能會佔用您裝置的大量磁碟空間。\",\n\t\"settingsMotion\": \"動畫\",\n\t\"settingsMotionAuto\": \"自動\",\n\t\"settingsMotionNormal\": \"正常\",\n\t\"settingsMotionReduced\": \"減少\",\n\t\"settingsPlaybackSpeed\": \"播放速度\",\n\t\"settingsPlaybackSpeedReset\": \"重設速度\",\n\t\"settingsPreparingForScan\": \"準備掃描\",\n\t\"settingsPreservePitch\": \"保持音高\",\n\t\"settingsPreservePitchInfo\": \"在變更播放速度時，保持人聲與樂器的原始音高不變。\",\n\t\"settingsPrimaryColor\": \"應用程式主色\",\n\t\"settingsScanInProgress\": \"正在掃描曲目。{current}/{total}\",\n\t\"settingsScanNewOrUpdatedTracks\": \"找到 {newTracks} 首新的或更新的曲目\",\n\t\"settingsScanNoNewTracks\": \"未找到新的曲目\",\n\t\"settingsThemeAuto\": \"自動\",\n\t\"settingsThemeDark\": \"深色\",\n\t\"settingsThemeLight\": \"淺色\",\n\t\"settingsTracksInAppStorageTooltip\": \"這包含儲存在應用程式儲存空間中的曲目和/或您從 Snae Player v1 遷移的資料\",\n\t\"settingsTracksInsideAppMemory\": \"應用程式儲存空間中的曲目\",\n\n\t\"shuffle\": \"隨機播放\",\n\t\"successfullyRemovedTracks\": \"成功移除 {count} 首曲目\",\n\t\"track\": \"曲目\",\n\t\"trackAddToFavorites\": \"新增到收藏夾\",\n\n\t\"trackPlay\": \"播放 {name}\",\n\t\"trackRemoveFromFavorites\": \"從收藏夾中移除\",\n\t\"tracks\": \"曲目\",\n\t\"trackViewAlbum\": \"檢視專輯\",\n\t\"trackViewArtist\": \"檢視藝術家\",\n\t\"understood\": \"明白\",\n\t\"unknown\": \"未知\",\n\t\"validationMaxLength\": \"最多允許 {max} 個字元\",\n\t\"validationMinLength\": \"至少需要 {min} 個字元\",\n\n\t\"validationRequired\": \"必填欄位\",\n\t\"year\": \"年份\"\n}\n"
  },
  {
    "path": "netlify.toml",
    "content": "[build]\npublish = \"build/\"\ncommand = \"pnpm run build\"\n\n[build.environment]\nNODE_VERSION = \"24.15.0\"\n\n# V1 version of the app used different service worker file name\n[[redirects]]\nfrom = \"/sw.js\"\nto = \"/service-worker.js\"\nstatus = 200\nforce = true\n\n[[redirects]]\nfrom = \"/*\"\nto = \"/200.html\"\nstatus = 200\n\n[[headers]]\nfor = \"/*\"\n[headers.values]\nReferrer-Policy = \"strict-origin-when-cross-origin\"\nX-Content-Type-Options = \"nosniff\"\nX-Frame-Options = \"DENY\"\nX-XSS-Protection = \"1; mode=block\"\nContent-Security-Policy = \"frame-ancestors 'none'\"\nCross-Origin-Resource-Policy = \"same-origin\"\nCross-Origin-Embedder-Policy = \"require-corp\"\n\n[[headers]]\nfor = \"/manifest.webmanifest\"\n[headers.values]\nContent-Type = \"application/manifest+json; charset=UTF-8\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"local-music-pwa-next\",\n\t\"version\": \"0.0.1\",\n\t\"private\": true,\n\t\"type\": \"module\",\n\t\"sideEffects\": false,\n\t\"scripts\": {\n\t\t\"prepare\": \"svelte-kit sync\",\n\t\t\"dev\": \"vite dev\",\n\t\t\"build\": \"vite build\",\n\t\t\"preview\": \"vite preview\",\n\t\t\"type-check\": \"svelte-kit sync && svelte-check\",\n\t\t\"i18n-check\": \"node scripts/check-translations.ts\",\n\t\t\"biome-check\": \"biome check .\",\n\t\t\"biome-fix\": \"biome check . --write\",\n\t\t\"prettier-check\": \"prettier --check ./src\",\n\t\t\"prettier-fix\": \"prettier --write ./src\",\n\t\t\"compile-i18n\": \"paraglide-js compile --project ./project.inlang\",\n\t\t\"test\": \"vitest run\",\n\t\t\"gen-color-theme\": \"node scripts/gen-color-theme.ts\",\n\t\t\"knip\": \"knip\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@biomejs/biome\": \"2.4.14\",\n\t\t\"@inlang/paraglide-js\": \"2.18.0\",\n\t\t\"@resvg/resvg-js\": \"^2.6.2\",\n\t\t\"@sveltejs/adapter-static\": \"^3.0.10\",\n\t\t\"@sveltejs/kit\": \"^2.59.0\",\n\t\t\"@tailwindcss/vite\": \"^4.2.4\",\n\t\t\"@types/node\": \"^24.12.2\",\n\t\t\"@types/wicg-file-system-access\": \"^2023.10.7\",\n\t\t\"fake-indexeddb\": \"^6.2.5\",\n\t\t\"happy-dom\": \"^20.9.0\",\n\t\t\"image-size\": \"^2.0.2\",\n\t\t\"knip\": \"^6.11.0\",\n\t\t\"prettier\": \"^3.8.3\",\n\t\t\"prettier-plugin-svelte\": \"^3.5.1\",\n\t\t\"prettier-plugin-tailwindcss\": \"^0.8.0\",\n\t\t\"svelte\": \"5.55.5\",\n\t\t\"svelte-check\": \"^4.4.7\",\n\t\t\"tailwindcss\": \"^4.2.4\",\n\t\t\"typescript\": \"^6.0.3\",\n\t\t\"unplugin-auto-import\": \"^21.0.0\",\n\t\t\"vite\": \"8.0.10\",\n\t\t\"vitest\": \"^4.1.5\"\n\t},\n\t\"dependencies\": {\n\t\t\"@material/material-color-utilities\": \"^0.4.0\",\n\t\t\"@tanstack/virtual-core\": \"^3.14.0\",\n\t\t\"idb\": \"^8.0.3\",\n\t\t\"music-metadata\": \"^11.12.3\",\n\t\t\"tiny-invariant\": \"^1.3.3\",\n\t\t\"weak-lru-cache\": \"^1.2.2\"\n\t},\n\t\"engines\": {\n\t\t\"node\": \"24.15.0\"\n\t},\n\t\"packageManager\": \"pnpm@11.0.3\"\n}\n"
  },
  {
    "path": "patches/@material__material-color-utilities.patch",
    "content": "diff --git a/dynamiccolor/color_spec_2025.js b/dynamiccolor/color_spec_2025.js\nindex 8bef961c7c6127c028b98ee3305270be5247a0c2..271597946422c58b91a54c1e1a748d30350ac015 100644\n--- a/dynamiccolor/color_spec_2025.js\n+++ b/dynamiccolor/color_spec_2025.js\n@@ -18,7 +18,7 @@ import { Hct } from '../hct/hct.js';\n import * as math from '../utils/math_utils.js';\n import { ColorSpecDelegateImpl2021 } from './color_spec_2021.js';\n import { ContrastCurve } from './contrast_curve.js';\n-import { DynamicColor, extendSpecVersion } from './dynamic_color';\n+import { DynamicColor, extendSpecVersion } from './dynamic_color.js';\n import { ToneDeltaPair } from './tone_delta_pair.js';\n import { Variant } from './variant.js';\n /**\ndiff --git a/index.d.ts b/index.d.ts\nindex 0618f70eb5221eeed3cf7a2e5724940716490aa3..1c5b5d9af414afea61489898ce45e2c93f882e08 100644\n--- a/index.d.ts\n+++ b/index.d.ts\n@@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js';\n export * from './dynamiccolor/variant.js';\n export * from './hct/cam16.js';\n export * from './hct/hct.js';\n+export * from './hct/hct_solver.js';\n export * from './hct/viewing_conditions.js';\n export * from './palettes/core_palette.js';\n export * from './palettes/tonal_palette.js';\ndiff --git a/index.js b/index.js\nindex 0fede2e15730083a6c54ebd8cb0c36a1f3109486..ae708d01b37849c30f84dc628884f696e6527a3a 100644\n--- a/index.js\n+++ b/index.js\n@@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js';\n export * from './dynamiccolor/variant.js';\n export * from './hct/cam16.js';\n export * from './hct/hct.js';\n+export * from './hct/hct_solver.js';\n export * from './hct/viewing_conditions.js';\n export * from './palettes/core_palette.js';\n export * from './palettes/tonal_palette.js';\ndiff --git a/package.json b/package.json\nindex 30ca4ac79453a16cf6a124eb126575af26146a69..fbde7a1241338fb8bc250a24b383b64893b73dfb 100644\n--- a/package.json\n+++ b/package.json\n@@ -4,6 +4,7 @@\n   \"publishConfig\": {\n     \"access\": \"public\"\n   },\n+  \"sideEffects\": false,\n   \"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.\",\n   \"keywords\": [\n     \"material\",\ndiff --git a/scheme/scheme_content.js b/scheme/scheme_content.js\nindex e06c67bc68883b4a5210dc4241544ed79dec905c..29d62f7514f53d30402ee2c002801d90a2938e2f 100644\n--- a/scheme/scheme_content.js\n+++ b/scheme/scheme_content.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A scheme that places the source color in `Scheme.primaryContainer`.\ndiff --git a/scheme/scheme_expressive.js b/scheme/scheme_expressive.js\nindex 43d05c6a9989566f2f300e8bf01fa4f1a8e120f0..baf7ca348f18ddc82e8d1f4df26161b70fbb578d 100644\n--- a/scheme/scheme_expressive.js\n+++ b/scheme/scheme_expressive.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A Dynamic Color theme that is intentionally detached from the source color.\ndiff --git a/scheme/scheme_fidelity.js b/scheme/scheme_fidelity.js\nindex a7461cdd4b49a49e642cd1060fba95ea1b6c7a1e..602d1eec140103f4f9501d9a84238eece61416f9 100644\n--- a/scheme/scheme_fidelity.js\n+++ b/scheme/scheme_fidelity.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A scheme that places the source color in `Scheme.primaryContainer`.\ndiff --git a/scheme/scheme_fruit_salad.js b/scheme/scheme_fruit_salad.js\nindex 87443afa8bb09d9c6f67be12fde55b7db9b9f37a..aeaff555801de637d3b8005eed30718d88c362df 100644\n--- a/scheme/scheme_fruit_salad.js\n+++ b/scheme/scheme_fruit_salad.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A playful theme - the source color's hue does not appear in the theme.\ndiff --git a/scheme/scheme_monochrome.js b/scheme/scheme_monochrome.js\nindex 30ad712ad104e397788ff3d8e11ea28ca00c0533..d18d656c4f98bbd4a1340feeaeff390f9a8b83fc 100644\n--- a/scheme/scheme_monochrome.js\n+++ b/scheme/scheme_monochrome.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /** A Dynamic Color theme that is grayscale. */\n export class SchemeMonochrome extends DynamicScheme {\ndiff --git a/scheme/scheme_neutral.js b/scheme/scheme_neutral.js\nindex 0d03a5e0f0200feb471860b48d5243dd5da5429d..e4cffaaa9d66bd056fded64bfb0ce43341789c66 100644\n--- a/scheme/scheme_neutral.js\n+++ b/scheme/scheme_neutral.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /** A Dynamic Color theme that is near grayscale. */\n export class SchemeNeutral extends DynamicScheme {\ndiff --git a/scheme/scheme_rainbow.js b/scheme/scheme_rainbow.js\nindex 65e6d3fd934ed07a2e6efdf6c552c306873d26e2..f80be9ae6da0d181c18a064ca4c645835abf9f86 100644\n--- a/scheme/scheme_rainbow.js\n+++ b/scheme/scheme_rainbow.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A playful theme - the source color's hue does not appear in the theme.\ndiff --git a/scheme/scheme_tonal_spot.js b/scheme/scheme_tonal_spot.js\nindex 6c506c3c23279e5842757b991cd066ef266fceea..c48f498c41e0cde98f34c3d7394a75ef1bf764cd 100644\n--- a/scheme/scheme_tonal_spot.js\n+++ b/scheme/scheme_tonal_spot.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A Dynamic Color theme with low to medium colorfulness and a Tertiary\ndiff --git a/scheme/scheme_vibrant.js b/scheme/scheme_vibrant.js\nindex cba1172e7d7ccae62d03e635c38063153bc5a61a..3a37230ad4b589c7d3071dbb093335eab0d71f4c 100644\n--- a/scheme/scheme_vibrant.js\n+++ b/scheme/scheme_vibrant.js\n@@ -14,7 +14,7 @@\n  * See the License for the specific language governing permissions and\n  * limitations under the License.\n  */\n-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';\n+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';\n import { Variant } from '../dynamiccolor/variant.js';\n /**\n  * A Dynamic Color theme that maxes out colorfulness at each position in the\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "engineStrict: true\n\nallowBuilds:\n  '@biomejs/biome': false\n  '@tailwindcss/oxide': false\n\npatchedDependencies:\n  '@material/material-color-utilities': 'patches/@material__material-color-utilities.patch'\n"
  },
  {
    "path": "project.inlang/settings.json",
    "content": "{\n\t\"$schema\": \"https://inlang.com/schema/project-settings\",\n\t\"baseLocale\": \"en\",\n\t\"locales\": [\"en\", \"lt\", \"de\", \"fr\", \"zh-CN\", \"zh-TW\"],\n\t\"modules\": [\n\t\t\"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js\",\n\t\t\"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js\"\n\t],\n\t\"plugin.inlang.messageFormat\": {\n\t\t\"pathPattern\": \"./messages/{languageTag}.json\"\n\t}\n}\n"
  },
  {
    "path": "scripts/check-translations.ts",
    "content": "import projectSettings from '../project.inlang/settings.json' with { type: 'json' }\n\ntype Messages = Record<string, string>\n\ninterface LocaleIssues {\n\tmissingKeys: string[]\n\tparamMismatches: string[]\n}\n\ninterface LocaleReport {\n\tlocale: string\n\tissues: LocaleIssues\n}\n\ninterface BaseMessageWithParams {\n\tvalue: string\n\tparams: string[]\n}\n\nconst extractParams = (value: string): string[] => {\n\tconst paramsRegex = /{(\\w+)}/g\n\tconst params: string[] = []\n\n\tconst matches = value.matchAll(paramsRegex)\n\tfor (const match of matches) {\n\t\tparams.push(match[1])\n\t}\n\n\treturn params\n}\n\nconst getMessages = async (locale: string): Promise<Messages> => {\n\tconst module = (await import(`../messages/${locale}.json`, { with: { type: 'json' } })) as {\n\t\tdefault: Messages\n\t}\n\n\tdelete module.default.$schema\n\n\treturn module.default\n}\n\nconst checkLocale = async (locale: string, baseMessagesMap: Map<string, BaseMessageWithParams>) => {\n\tconst messages = await getMessages(locale)\n\tconst issues: LocaleIssues = {\n\t\tmissingKeys: [],\n\t\tparamMismatches: [],\n\t}\n\n\tfor (const [key, baseData] of baseMessagesMap) {\n\t\tif (key in messages) {\n\t\t\tconst localeParams = extractParams(messages[key])\n\n\t\t\tconst missingParams = baseData.params.filter((param) => !localeParams.includes(param))\n\t\t\tconst extraParams = localeParams.filter((param) => !baseData.params.includes(param))\n\n\t\t\tif (missingParams.length > 0) {\n\t\t\t\tissues.paramMismatches.push(key)\n\t\t\t} else if (extraParams.length > 0) {\n\t\t\t\tissues.paramMismatches.push(key)\n\t\t\t}\n\t\t} else {\n\t\t\tissues.missingKeys.push(key)\n\t\t}\n\t}\n\n\treturn {\n\t\tlocale,\n\t\tissues,\n\t}\n}\n\nconst printReport = (reports: LocaleReport[]) => {\n\tlet hasAnyIssues = false\n\n\tfor (const report of reports) {\n\t\tconst { locale, issues } = report\n\n\t\tif (issues.paramMismatches.length === 0 && issues.missingKeys.length === 0) {\n\t\t\tconsole.info(`✅ Locale \"${locale}\" has no issues`)\n\t\t} else {\n\t\t\thasAnyIssues = true\n\t\t}\n\n\t\tif (issues.missingKeys.length > 0) {\n\t\t\tconsole.info(`❌ \"${locale}\" missing keys:`)\n\t\t\tconsole.info(issues.missingKeys)\n\t\t}\n\n\t\tif (issues.paramMismatches.length > 0) {\n\t\t\tconsole.info(`❌ \"${locale}\" has param mismatches in keys:`)\n\t\t\tconsole.info(issues.paramMismatches)\n\t\t}\n\t}\n\n\tif (hasAnyIssues) {\n\t\tprocess.exit(1)\n\t} else {\n\t\tprocess.exit(0)\n\t}\n}\n\nconst baseMessages = await getMessages(projectSettings.baseLocale)\nconst baseMessagesMap = new Map<string, BaseMessageWithParams>()\n\nfor (const [key, value] of Object.entries(baseMessages)) {\n\tbaseMessagesMap.set(key, {\n\t\tvalue,\n\t\tparams: extractParams(value),\n\t})\n}\n\nconst reports: LocaleReport[] = []\n\nfor (const locale of projectSettings.locales) {\n\tif (locale !== projectSettings.baseLocale) {\n\t\tconst report = await checkLocale(locale, baseMessagesMap)\n\t\treports.push(report)\n\t}\n}\n\nprintReport(reports)\n"
  },
  {
    "path": "scripts/gen-color-theme.ts",
    "content": "import { writeFileSync } from 'node:fs'\nimport {\n\targbFromHex,\n\t// biome-ignore lint/style/noRestrictedImports: Used for static theme generation\n} from '@material/material-color-utilities'\nimport { getThemePaletteRgbEntries } from '../src/lib/theme.ts'\n\nconst defaultColorSeed = '#cc9724'\nconst outputFile = `${import.meta.dirname}/../src/theme-colors.css`\n\nconst argb = argbFromHex(defaultColorSeed)\n\nconst tokensLightEntries = getThemePaletteRgbEntries(argb, false)\nconst tokensDark = Object.fromEntries(getThemePaletteRgbEntries(argb, true))\n\nconst variables = tokensLightEntries\n\t.map(([name, lightValue]) => `--color-${name}: light-dark(${lightValue}, ${tokensDark[name]});`)\n\t.join('\\n\t')\n\nconst content = `/* This file is auto generated, do not edit manually. */\n@theme {\n\t--color-*: initial;\n\t--color-transparent: transparent;\n\t--color-current: currentColor;\n\t${variables}\n}\n`\n\nwriteFileSync(outputFile, content, {\n\tencoding: 'utf-8',\n})\n"
  },
  {
    "path": "src/ambient.d.ts",
    "content": "declare module '*?as=metadata' {\n\tconst metadata: {\n\t\tsrc: string\n\t\twidth: number\n\t\theight: number\n\t}\n\n\texport default metadata\n}\n"
  },
  {
    "path": "src/app.css",
    "content": "@import 'tailwindcss';\n@import './theme-colors.css';\n\n/* We don't use these classes */\n@source not inline('container');\n\n/* latin */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: swap;\n\tsrc: url('/fonts/Heebo.latin.woff2') format('woff2');\n\tunicode-range:\n\t\tU+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,\n\t\tU+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* latin-ext */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: swap;\n\tsrc: url('/fonts/Heebo.latin-ext.woff2') format('woff2');\n\tunicode-range:\n\t\tU+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,\n\t\tU+A720-A7FF;\n}\n/* cyrillic */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: swap;\n\tsrc: url('/fonts/Heebo.cyrillic.woff2') format('woff2');\n\tunicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* cyrillic-ext */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: swap;\n\tsrc: url('/fonts/Heebo.cyrillic-ext.woff2') format('woff2');\n\tunicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* greek */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: swap;\n\tsrc: url('/fonts/Heebo.greek.woff2') format('woff2');\n\tunicode-range: U+0370-03FF;\n}\n/* greek-ext */\n@font-face {\n\tfont-family: 'Heebo';\n\tfont-style: normal;\n\tfont-display: fallback;\n\tsrc: url('/fonts/Heebo.greek-ext.woff2') format('woff2');\n\tunicode-range: U+1F00-1FFF;\n}\n\n@font-face {\n\tfont-family: 'App CJK Fallback';\n\tsrc:\n\t\tlocal('Noto Sans SC'), local('Noto Sans CJK SC'), local('Noto Sans TC'),\n\t\tlocal('Noto Sans CJK TC'), local('Noto Sans HK'), local('Noto Sans CJK HK'),\n\t\tlocal('PingFang SC'), local('PingFang TC'), local('Microsoft YaHei'),\n\t\tlocal('Microsoft JhengHei');\n\tfont-style: normal;\n\tfont-weight: 400 900;\n\tfont-display: swap;\n\tunicode-range:\n\t\tU+3000-303F, /* CJK punctuation */ U+3400-4DBF, /* CJK Ext A */ U+4E00-9FFF,\n\t\t/* CJK Unified Ideographs */ U+F900-FAFF, /* CJK Compatibility Ideographs */ U+FF00-FFEF; /* Half/Fullwidth */\n}\n\n@theme {\n\t--breakpoint-xs: 24rem;\n\t--breakpoint-xss: 20rem;\n\n\t--ease-*: initial;\n\t--ease-standard: cubic-bezier(0.2, 0, 0, 1);\n\t--ease-outgoing40: cubic-bezier(0.4, 0, 1, 1);\n\t--ease-incoming80: cubic-bezier(0, 0, 0.2, 1);\n\t--ease-incoming80outgoing40: cubic-bezier(0.4, 0, 0.2, 1);\n\n\t--font-sans: 'Heebo', 'App CJK Fallback', system-ui, 'Noto Color Emoji', sans-serif;\n\t--text-*: initial;\n}\n\nhtml {\n\tbackground-color: var(--color-surface);\n\tcolor: var(--color-onSurface);\n\tfont-family: var(--font-sans);\n\tfont-optical-sizing: auto;\n\tcolor-scheme: light;\n\twidth: 100%;\n\toverflow-y: scroll;\n\toverflow-x: hidden;\n\ttouch-action: manipulation;\n\t-webkit-touch-callout: none; /* Disable the iOS popup when long-press on a link */\n\t/* Chrome implementation with fixed elements is very buggy */\n\t/* scrollbar-gutter: stable; */\n\tscroll-padding-top: var(--app-header-height);\n\tscroll-padding-bottom: var(--bottom-overlay-height);\n\t--app-header-height: --spacing(16);\n\t--app-max-content-width: --spacing(400);\n\t--mktg-content-max-width: --spacing(300);\n}\n\nhtml.dark {\n\tcolor-scheme: dark;\n}\n\nhtml,\nbody {\n\tmin-height: 100dvh;\n}\n\nbody {\n\t-webkit-tap-highlight-color: transparent;\n\tdisplay: flex;\n\tflex-direction: column;\n\t/* Needed for Safari */\n\t-webkit-user-select: none;\n\tuser-select: none;\n\t@apply text-body-md;\n}\n\n#app {\n\tdisplay: flex;\n\tflex-direction: column;\n\tflex-grow: 1;\n}\n\nsource {\n\tdisplay: none;\n}\n\n@keyframes fade-in {\n\tfrom {\n\t\topacity: 0;\n\t}\n\tto {\n\t\topacity: 1;\n\t}\n}\n\n@keyframes fade-out {\n\tfrom {\n\t\topacity: 1;\n\t}\n\tto {\n\t\topacity: 0;\n\t}\n}\n\n@layer base {\n\t* {\n\t\tscrollbar-width: thin;\n\t\tscrollbar-color: --alpha(var(--color-secondary) / 30%) transparent;\n\t}\n\n\t:where(:focus) {\n\t\toutline: none;\n\t}\n\n\t:where(:focus-visible) {\n\t\toutline: --spacing(0.5) solid var(--color-onSurface);\n\t\toutline-offset: --spacing(0.5);\n\t}\n\n\tstrong {\n\t\tfont-weight: 600;\n\t}\n}\n\n@keyframes tooltip-fade-in {\n\tfrom {\n\t\topacity: 0;\n\t}\n\tto {\n\t\topacity: 1;\n\t}\n}\n\n.tooltip {\n\tposition: fixed;\n\tposition-area: bottom;\n\tposition-try-fallbacks: top, left, right;\n\tmargin: 8px;\n\tanimation: tooltip-fade-in 0.3s ease-out;\n}\n\n@utility interactable {\n\toverflow: hidden;\n\tappearance: none;\n\tborder: none;\n\toutline-width: 0;\n\ttext-decoration: none;\n\tcursor: pointer;\n\tdisplay: flex;\n\talign-items: center;\n\tz-index: 0;\n\tposition: relative;\n\ttransition: outline-width 150ms linear;\n\n\t&::after {\n\t\tdisplay: none;\n\t\tcontent: '';\n\t\tposition: absolute;\n\t\theight: 100%;\n\t\twidth: 100%;\n\t\tleft: 0;\n\t\ttop: 0;\n\t\tbackground: currentColor;\n\t\tz-index: -1;\n\t\tpointer-events: none;\n\t\topacity: 0;\n\t\ttransition:\n\t\t\topacity 0.2s linear,\n\t\t\tdisplay 0.2s allow-discrete;\n\t}\n\n\t@media (any-hover: hover) {\n\t\t&:hover::after {\n\t\t\tdisplay: block;\n\t\t\topacity: 0.08;\n\t\t}\n\n\t\t&[disabled]::after {\n\t\t\tdisplay: none;\n\t\t}\n\t}\n\n\t&:is(:focus-visible),\n\t&:hover:focus-visible {\n\t\toutline-width: --spacing(0.5);\n\t}\n\n\t&:focus-visible::after {\n\t\tdisplay: block;\n\t\topacity: 0.12;\n\t}\n\n\t@starting-style {\n\t\t&::after {\n\t\t\topacity: 0 !important;\n\t\t}\n\t}\n}\n\n@utility flip-x {\n\ttransform: scaleX(-1);\n}\n\n@utility flip-y {\n\ttransform: scaleY(-1);\n}\n\n@utility text-headline-lg {\n\tfont-size: --spacing(8);\n\tline-height: --spacing(10);\n\tletter-spacing: 0;\n\tfont-weight: 700;\n}\n\n@utility text-headline-md {\n\tfont-size: --spacing(7);\n\tline-height: --spacing(9);\n\tletter-spacing: 0;\n\tfont-weight: 400;\n}\n\n@utility text-headline-sm {\n\tfont-size: --spacing(6);\n\tline-height: --spacing(8);\n\tletter-spacing: 0;\n\tfont-weight: 400;\n}\n\n@utility text-title-lg {\n\tfont-size: --spacing(5.5);\n\tline-height: --spacing(7);\n\tletter-spacing: 0;\n\tfont-weight: 500;\n}\n\n@utility text-title-md {\n\tfont-size: --spacing(4);\n\tline-height: --spacing(6);\n\tletter-spacing: 0.15px;\n\tfont-weight: 500;\n}\n\n@utility text-title-sm {\n\tfont-size: --spacing(3.5);\n\tline-height: --spacing(5);\n\tletter-spacing: 0.1px;\n\tfont-weight: 500;\n}\n\n@utility text-label-lg {\n\tfont-size: --spacing(3.5);\n\tline-height: --spacing(5);\n\tletter-spacing: 0.1px;\n\tfont-weight: 500;\n}\n\n@utility text-label-md {\n\tfont-size: 12px;\n\tline-height: --spacing(4);\n\tletter-spacing: 0.5px;\n\tfont-weight: 500;\n}\n\n@utility text-label-sm {\n\tfont-size: 11px;\n\tline-height: --spacing(4);\n\tletter-spacing: 0.5px;\n\tfont-weight: 500;\n}\n\n@utility text-body-lg {\n\tfont-size: --spacing(4);\n\tline-height: --spacing(6);\n\tletter-spacing: 0.15px;\n\tfont-weight: 400;\n}\n\n@utility text-body-md {\n\tfont-size: --spacing(3.5);\n\tline-height: --spacing(5);\n\tletter-spacing: 0.25px;\n\tfont-weight: 400;\n}\n\n@utility text-body-sm {\n\tfont-size: --spacing(3);\n\tline-height: --spacing(4);\n\tletter-spacing: 0.4px;\n\tfont-weight: 400;\n}\n\n@utility view-name-* {\n\tview-transition-name: --value([*]);\n}\n\n@utility scrollbar-gutter-stable {\n\tscrollbar-gutter: stable;\n}\n\n@utility stack-in-grid {\n\tgrid-area: 1 / 1;\n}\n\n@custom-variant active-view-player {\n\thtml:active-view-transition-type(player) & {\n\t\t@slot;\n\t}\n}\n\n@custom-variant active-view-regular {\n\thtml:active-view-transition-type(regular) & {\n\t\t@slot;\n\t}\n}\n\n@layer components {\n\t.link {\n\t\ttext-decoration: underline;\n\t}\n\n\t.mktg-content-width {\n\t\twidth: 100%;\n\t\tmax-width: var(--mktg-content-max-width);\n\t\tpadding-left: --spacing(6);\n\t\tpadding-right: --spacing(6);\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t\talign-items: center;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t}\n\n\t.mktg-content-width-using-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: minmax(0, var(--mktg-content-max-width));\n\t\tjustify-content: center;\n\t}\n\n\t.card {\n\t\tbackground-color: var(--color-surfaceContainer);\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tborder-radius: --spacing(2);\n\t\tcolor: var(--color-onSurface);\n\t}\n\n\t.virtual-item {\n\t\tposition: absolute !important;\n\t\tcontain: strict;\n\t\twill-change: transform;\n\t}\n\n\t.ripple {\n\t\twidth: --spacing(1);\n\t\theight: --spacing(1);\n\t\tposition: absolute;\n\t\tborder-radius: 50%;\n\t\topacity: 0.2;\n\t\tbackground-color: currentColor;\n\t\tanimation-fill-mode: both;\n\t\tcontain: strict;\n\t\twill-change: transform, opacity;\n\t\tpointer-events: none;\n\t}\n}\n\n/* Hide Netlify preview bar */\ndiv[data-netlify-deploy-id] {\n\tdisplay: none;\n}\n"
  },
  {
    "path": "src/app.d.ts",
    "content": "import type { Snippet as SnippetInternal } from 'svelte'\nimport type { ClassValue as ClassValueInternal } from 'svelte/elements'\n\ndeclare module '$env/static/public' {\n\tconst PUBLIC_FALLBACK_PAGE: string\n\tconst PUBLIC_GOAT_COUNTER_URL: string\n}\n\ndeclare global {\n\tnamespace App {\n\t\t// interface Error {}\n\t\t// interface Locals {}\n\t\tinterface PageData {\n\t\t\tnoPlayerOverlay?: boolean\n\t\t\thtmlOverflow?: 'auto' | 'default'\n\t\t}\n\t\t// interface Platform {}\n\t}\n\n\t// Not using unplugin auto import because because when used getting error:\n\t// Exported variable 'Foo' has or is using private name 'ParentChild'\n\ttype ClassValue = ClassValueInternal\n\ttype Snippet<Parameters extends unknown[] = []> = SnippetInternal<Parameters>\n\n\tinterface Navigator {\n\t\t// Optional because Safari and Firefox don't support it\n\t\tuserAgentData?: {\n\t\t\tmobile: boolean\n\t\t\tplatform: 'macOS' | 'Windows' | (string & {})\n\t\t\tbrands: {\n\t\t\t\tbrand: string\n\t\t\t\tversion: string\n\t\t\t}[]\n\t\t}\n\t}\n\n\t/**\n\t * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler\n\t * before a user is prompted to \"install\" a web site to a home screen on mobile.\n\t */\n\tinterface BeforeInstallPromptEvent extends Event {\n\t\t/**\n\t\t * Returns an array of DOMString items containing the platforms on which the event was dispatched.\n\t\t * This is provided for user agents that want to present a choice of versions to the user such as,\n\t\t * for example, \"web\" or \"play\" which would allow the user to chose between a web version or\n\t\t * an Android version.\n\t\t */\n\t\treadonly platforms: string[]\n\n\t\t/**\n\t\t * Returns a Promise that resolves to a DOMString containing either \"accepted\" or \"dismissed\".\n\t\t */\n\t\treadonly userChoice: Promise<{\n\t\t\toutcome: 'accepted' | 'dismissed'\n\t\t\tplatform: string\n\t\t}>\n\n\t\t/**\n\t\t * Allows a developer to show the install prompt at a time of their own choosing.\n\t\t * This method returns a Promise.\n\t\t */\n\t\tprompt: () => Promise<void>\n\t}\n\n\tinterface WindowEventMap {\n\t\tbeforeinstallprompt: BeforeInstallPromptEvent\n\t}\n\n\tinterface GoatCounter {\n\t\tcount: (data: { path: string; title?: string; event?: boolean }) => void\n\t}\n\n\tinterface Window {\n\t\t/** Analytics. If ad blocker blocks it this will be undefined */\n\t\tgoatcounter?: GoatCounter\n\t}\n\n\t// All modern browsers use PointerEvent instead of MouseEvent for\n\t// click, dblclick, and contextmenu. Since we can't change global\n\t// type easily we just add missing properties to MouseEvent to make it compatible with PointerEvent.\n\tinterface MouseEvent {\n\t\tpointerType: 'mouse' | 'pen' | 'touch'\n\t}\n}\n"
  },
  {
    "path": "src/app.html",
    "content": "<!doctype html>\n<html lang=\"en\" class=\"dark\" data-sveltekit-preload-data=\"false\">\n\t<head>\n\t\t<meta charset=\"utf-8\">\n\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n\t\t%snae.theme-color-meta%\n\n\t\t<title>Snae Player</title>\n\n\t\t<meta name=\"description\" content=\"%snae.description%\">\n\n\t\t<link rel=\"icon\" href=\"/icons/raster-16.png\" sizes=\"16x16\" type=\"image/png\">\n\t\t<link rel=\"icon\" href=\"/icons/raster-32.png\" sizes=\"32x32\" type=\"image/png\">\n\t\t<link rel=\"icon\" href=\"/icons/raster-48.png\" sizes=\"48x48\" type=\"image/png\">\n\t\t<link rel=\"icon\" href=\"/icons/raster-128.png\" sizes=\"128x128\" type=\"image/png\">\n\t\t<link rel=\"icon\" href=\"/icons/raster-192.png\" sizes=\"192x192\" type=\"image/png\">\n\n\t\t<link rel=\"icon\" href=\"/icons/responsive.svg\" type=\"image/svg+xml\">\n\n\t\t<link\n\t\t\trel=\"preload\"\n\t\t\thref=\"/fonts/Heebo.latin.woff2\"\n\t\t\tas=\"font\"\n\t\t\ttype=\"font/woff2\"\n\t\t\tcrossorigin=\"anonymous\"\n\t\t>\n\t\t%sveltekit.head%\n\n\t\t<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n\t</head>\n\n\t<body>\n\t\t<noscript>Please enable Javascript in order to use this app.</noscript>\n\t\t<div id=\"unsupported-browser\" hidden class=\"m-auto rounded-2xl bg-[dimgray] p-5 text-[white]\">\n\t\t\t<div>\n\t\t\t\tThis browser does not support required technologies for this app to function correctly.\n\t\t\t</div>\n\t\t\t<div>Please upgrade your browser to the latest version of:</div>\n\t\t\t<a class=\"link text-primary\" href=\"https://www.mozilla.org/en-US/firefox/new/\">Firefox</a>\n\t\t\t<a class=\"link text-primary\" href=\"https://www.microsoft.com/en-us/windows/microsoft-edge\">\n\t\t\t\tEdge\n\t\t\t</a>\n\t\t\t<a class=\"link text-primary\" href=\"https://www.google.com/chrome/\">Chrome</a>\n\t\t\t<a class=\"link text-primary\" href=\"https://www.apple.com/safari/\">Safari</a>\n\t\t</div>\n\t\t%snae.svg-icons-paths%\n\n\t\t<div id=\"app\">%sveltekit.body%</div>\n\n\t\t<script\n\t\t\tsrc=\"https://gc.zgo.at/count.js\"\n\t\t\tdata-goatcounter=\"%snae.goat-counter-url%/count\"\n\t\t\tcrossorigin=\"anonymous\"\n\t\t\tasync\n\t\t\tdata-goatcounter-settings='{\"no_onload\": true}'\n\t\t></script>\n\n\t\t<script defer src=\"/supported-browser-check.js\"></script>\n\t</body>\n</html>\n"
  },
  {
    "path": "src/hooks.server.ts",
    "content": "import type { Handle } from '@sveltejs/kit'\nimport { APP_DESCRIPTION_EN } from '$lib/app-metadata.ts'\nimport { ICON_PATHS } from '$lib/components/icon/icon-paths.server.ts'\nimport { PUBLIC_FALLBACK_PAGE, PUBLIC_GOAT_COUNTER_URL } from '$env/static/public'\nimport { THEME_PALLETTE_DARK, THEME_PALLETTE_LIGHT } from './server/theme-colors.ts'\n\nconst getThemeColorMeta = (color: string | undefined, theme: 'dark' | 'light') =>\n\t`<meta name=\"theme-color\" content=\"${color}\" media=\"(prefers-color-scheme: ${theme})\" />`\n\nconst replaceThemeColorMeta = (html: string) =>\n\thtml.replace(\n\t\t'%snae.theme-color-meta%',\n\t\t`\n\t\t${getThemeColorMeta(THEME_PALLETTE_LIGHT.surface, 'light')}\n\t\t${getThemeColorMeta(THEME_PALLETTE_DARK.surface, 'dark')}\n\t\t`,\n\t)\n\nconst getSvgSymbol = (name: string, path: string) =>\n\t`<symbol id=\"system-icon-${name}\">\n\t\t<path d=\"${path}\" />\n\t</symbol>`\n\nconst replaceSvgIconPaths = (html: string) => {\n\tconst icons = Object.entries(ICON_PATHS)\n\n\t// Instead of keeping the icons paths in the client js bundle, we can inline them in the html\n\t// making loading tiny bit faster\n\treturn html.replace(\n\t\t'%snae.svg-icons-paths%',\n\t\t`\n\t\t<svg xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\" style=\"display: none;\">\n\t\t\t<defs>\n\t\t\t\t${icons.map(([name, path]) => getSvgSymbol(name, path)).join('')}\n\t\t\t</defs>\n\t\t</svg>`,\n\t)\n}\n\nconst replaceGoatCounterUrl = (html: string) =>\n\thtml.replaceAll('%snae.goat-counter-url%', PUBLIC_GOAT_COUNTER_URL)\n\nconst replaceDescription = (html: string) => html.replace('%snae.description%', APP_DESCRIPTION_EN)\n\nconst transformPageChunk = ({ html }: { html: string }) => {\n\thtml = replaceSvgIconPaths(html)\n\thtml = replaceThemeColorMeta(html)\n\thtml = replaceGoatCounterUrl(html)\n\thtml = replaceDescription(html)\n\n\treturn html\n}\n\n// This will only run in dev/preview or build and not in production\n// since we are using the static adapter\nexport const handle: Handle = async ({ event, resolve }) => {\n\t// Adding this so service-worker can properly cache the 200.html\n\tif (event.url.pathname === PUBLIC_FALLBACK_PAGE) {\n\t\tconst response = await resolve(event, { transformPageChunk })\n\n\t\treturn new Response(response.body, {\n\t\t\tstatus: 200,\n\t\t\theaders: response.headers,\n\t\t})\n\t}\n\n\treturn resolve(event, { transformPageChunk })\n}\n"
  },
  {
    "path": "src/lib/app-metadata.ts",
    "content": "export const APP_NAME_EN = 'Snae Player'\nexport const APP_NAME_SHORT_EN = 'Snae'\nexport const APP_DESCRIPTION_EN =\n\t'Play your local music in the browser with playlists, equalizer, playback speed, and offline listening. No uploads, no sign-up.'\n"
  },
  {
    "path": "src/lib/attachments/ripple.ts",
    "content": "import type { Attachment } from 'svelte/attachments'\nimport { on } from 'svelte/events'\nimport { assign } from '$lib/helpers/utils/assign.ts'\nimport { animateEmpty } from '../helpers/animations.ts'\n\nconst FADE_DURATION = 180\nconst SCALE_DURATION = 400\n\nconst createRippleSpan = () => {\n\tif (import.meta.env.SSR) {\n\t\treturn null as unknown as HTMLSpanElement\n\t}\n\n\tconst span = document.createElement('span')\n\tspan.className = 'ripple'\n\n\treturn span\n}\n\nconst rippleSpan = createRippleSpan()\nconst activeRipples = new Map<HTMLSpanElement, boolean>()\n\n/** @public */\nexport const getActiveRipplesCount = (): number => activeRipples.size\n\nconst markForOrExitRipple = (ripple: HTMLSpanElement) => {\n\tconst canExit = activeRipples.get(ripple)\n\n\tif (canExit) {\n\t\tconst fadeAni = ripple.animate(\n\t\t\t{ opacity: 0 },\n\t\t\t{\n\t\t\t\tduration: FADE_DURATION,\n\t\t\t\teasing: 'linear',\n\t\t\t},\n\t\t)\n\t\tfadeAni.finished.then(() => {\n\t\t\tactiveRipples.delete(ripple)\n\t\t\tripple.remove()\n\t\t})\n\t} else {\n\t\tactiveRipples.set(ripple, true)\n\t}\n}\n\nconst onExitHandler = () => {\n\tif (activeRipples.size === 0) {\n\t\treturn\n\t}\n\n\tfor (const ripple of activeRipples.keys()) {\n\t\tmarkForOrExitRipple(ripple)\n\t}\n}\n\nif (!import.meta.env.SSR) {\n\tdocument.addEventListener('pointercancel', onExitHandler, { passive: true })\n\tdocument.addEventListener('pointerup', onExitHandler, { passive: true })\n}\n\nconst onPointerDownHandler = (e: PointerEvent) => {\n\t// Only respond to main click events.\n\tif (e.button !== 0) {\n\t\treturn\n\t}\n\n\tconst node = e.currentTarget as HTMLElement\n\n\tif (node.hasAttribute('disabled')) {\n\t\treturn\n\t}\n\n\tconst rect = node.getBoundingClientRect()\n\n\tconst ripple = rippleSpan.cloneNode() as HTMLSpanElement\n\n\t// Use small value and scale it up to the right size,\n\t// because that way less GPU memory is used\n\t// when container is very big.\n\tconst realDiameter = 4\n\tconst realRadius = realDiameter / 2\n\n\tconst posX = e.clientX - rect.left\n\tconst posY = e.clientY - rect.top\n\n\tassign(ripple.style, {\n\t\ttop: `${posY - realRadius}px`,\n\t\tleft: `${posX - realRadius}px`,\n\t})\n\n\tactiveRipples.set(ripple, false)\n\tnode.appendChild(ripple)\n\n\t// Find absolute distance from center of the click\n\t// to the edge of the container.\n\tconst distanceToCX = Math.max(posX, rect.width - posX)\n\tconst distanceToCY = Math.max(posY, rect.height - posY)\n\tconst distanceC = Math.max(distanceToCX, distanceToCY)\n\n\t// Place square inside the container so it fills all available space,\n\t// then draw circle around it. This is basic idea of this calculation.\n\tconst squareSide = distanceC * 2\n\tconst diameter = Math.sqrt(squareSide ** 2 * 2)\n\n\tconst scaleValue = diameter / realDiameter\n\n\tripple.animate(\n\t\t{ transform: ['scale(0)', `scale(${scaleValue})`] },\n\t\t{\n\t\t\tduration: SCALE_DURATION,\n\t\t\teasing: 'cubic-bezier(0.4, 0, 0.2, 1)',\n\t\t\tfill: 'both',\n\t\t},\n\t)\n\n\tanimateEmpty(ripple, SCALE_DURATION - FADE_DURATION).finished.then(() =>\n\t\tmarkForOrExitRipple(ripple),\n\t)\n}\n\nexport interface RippleOptions {\n\tstopPropagation?: boolean\n}\n\nexport const ripple =\n\t(options: RippleOptions = {}): Attachment<HTMLElement> =>\n\t(node) => {\n\t\tconst cleanup = on(node, 'pointerdown', (e) => {\n\t\t\tif (options?.stopPropagation) {\n\t\t\t\te.stopPropagation()\n\t\t\t}\n\n\t\t\tonPointerDownHandler(e)\n\t\t})\n\t\treturn cleanup\n\t}\n"
  },
  {
    "path": "src/lib/attachments/tooltip.ts",
    "content": "import type { Attachment } from 'svelte/attachments'\nimport { on } from 'svelte/events'\nimport { browser } from '$app/environment'\n\nlet tooltipTemplate: HTMLDivElement | null = null\nconst cloneTooltipTemplate = () => {\n\tif (tooltipTemplate === null) {\n\t\ttooltipTemplate = document.createElement('div')\n\t\ttooltipTemplate.setAttribute('role', 'tooltip')\n\t\ttooltipTemplate.className =\n\t\t\t'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'\n\t\ttooltipTemplate.popover = 'manual'\n\t}\n\n\treturn tooltipTemplate.cloneNode() as HTMLDivElement\n}\n\nconst supportsCssAnchor = browser && CSS.supports('anchor-name', '--a')\nlet tooltipCounter = 0\n\nexport const tooltip = (message: string | undefined): Attachment<HTMLElement> => {\n\ttooltipCounter += 1\n\tconst anchorName = `--tooltip-${tooltipCounter}`\n\n\treturn (target) => {\n\t\tif (!message || import.meta.env.SSR || !supportsCssAnchor) {\n\t\t\treturn\n\t\t}\n\n\t\ttarget.setAttribute('title', message)\n\n\t\tlet tooltipElement: HTMLElement | null = null\n\t\tlet timeoutId: number | null = null\n\t\tconst controller = new AbortController()\n\t\tconst { signal } = controller\n\n\t\tconst clearTooltipTimeout = () => {\n\t\t\tif (timeoutId) {\n\t\t\t\twindow.clearTimeout(timeoutId)\n\t\t\t\ttimeoutId = null\n\t\t\t}\n\t\t}\n\n\t\tconst showTooltip = () => {\n\t\t\tif (tooltipElement || !message) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Remove attribute to prevent default browser tooltip\n\t\t\ttarget.removeAttribute('title')\n\t\t\ttarget.style.anchorName = anchorName\n\n\t\t\ttooltipElement = cloneTooltipTemplate()\n\t\t\ttooltipElement.textContent = message\n\t\t\ttooltipElement.style.positionAnchor = anchorName\n\n\t\t\tdocument.body.appendChild(tooltipElement)\n\t\t\ttooltipElement.showPopover()\n\t\t}\n\n\t\tconst scheduleShowTooltip = () => {\n\t\t\tif (tooltipElement) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttimeoutId = window.setTimeout(showTooltip, 300)\n\t\t}\n\n\t\tconst hideTooltip = () => {\n\t\t\tclearTooltipTimeout()\n\t\t\t// Restore the title attribute\n\t\t\tif (message) {\n\t\t\t\ttarget.setAttribute('title', message)\n\t\t\t}\n\n\t\t\ttarget.style.removeProperty('anchor-name')\n\t\t\tif (tooltipElement) {\n\t\t\t\ttooltipElement.remove()\n\t\t\t\ttooltipElement = null\n\t\t\t}\n\t\t}\n\n\t\ton(target, 'pointerenter', scheduleShowTooltip, { signal })\n\t\ton(\n\t\t\ttarget,\n\t\t\t'focusin',\n\t\t\t() => {\n\t\t\t\tif (target.matches(':focus-visible')) {\n\t\t\t\t\tscheduleShowTooltip()\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ signal },\n\t\t)\n\n\t\ton(target, 'pointerleave', hideTooltip, { signal })\n\t\t// Makes so tooltip is hidden just before view transitions starts\n\t\ton(target, 'pointerup', hideTooltip, { signal })\n\t\ton(target, 'focusout', hideTooltip, { signal })\n\t\t// Needed for Safari\n\t\ton(target, 'touchend', hideTooltip, { signal })\n\n\t\tconst cleanup = () => {\n\t\t\tcontroller.abort()\n\t\t\thideTooltip()\n\t\t}\n\n\t\treturn cleanup\n\t}\n}\n"
  },
  {
    "path": "src/lib/components/AlbumsListContainer.svelte",
    "content": "<script lang=\"ts\">\n\timport { formatArtists, formatNameOrUnknown } from '$lib/helpers/utils/text.ts'\n\timport LibraryGridListContainer from './library-grid/LibraryGridListContainer.svelte'\n\n\tinterface Props {\n\t\titems: readonly number[]\n\t}\n\n\tconst { items }: Props = $props()\n</script>\n\n<LibraryGridListContainer type=\"albums\" {items}>\n\t{#snippet item(album)}\n\t\t<div class=\"truncate text-onSurface\">\n\t\t\t{formatNameOrUnknown(album.name)}\n\t\t</div>\n\t\t<div class=\"truncate\">\n\t\t\t{formatArtists(album.artists)}\n\t\t</div>\n\t{/snippet}\n</LibraryGridListContainer>\n"
  },
  {
    "path": "src/lib/components/ArtistListContainer.svelte",
    "content": "<script lang=\"ts\">\n\timport LibraryGridListContainer from '$lib/components/library-grid/LibraryGridListContainer.svelte'\n\timport { formatNameOrUnknown } from '$lib/helpers/utils/text'\n\n\tinterface Props {\n\t\titems: number[]\n\t}\n\n\tconst { items }: Props = $props()\n</script>\n\n<LibraryGridListContainer type=\"artists\" {items}>\n\t{#snippet item(artist)}\n\t\t<div class=\"truncate text-onSurface\">\n\t\t\t{formatNameOrUnknown(artist.name)}\n\t\t</div>\n\t{/snippet}\n</LibraryGridListContainer>\n"
  },
  {
    "path": "src/lib/components/Artwork.svelte",
    "content": "<script lang=\"ts\">\n\timport type { IconType } from './icon/Icon.svelte'\n\timport Icon from './icon/Icon.svelte'\n\n\tinterface Props {\n\t\tsrc: string | undefined\n\t\tclass?: ClassValue\n\t\talt?: string\n\t\tfallbackIcon?: IconType | false\n\t\tnoFallbackBg?: boolean\n\t\tchildren?: Snippet\n\t}\n\n\tconst {\n\t\tsrc,\n\t\tfallbackIcon = 'musicNote',\n\t\tnoFallbackBg,\n\t\tclass: className,\n\t\talt,\n\t\tchildren,\n\t}: Props = $props()\n\n\tlet error = $state(false)\n\n\t$effect(() => {\n\t\tvoid src\n\n\t\tuntrack(() => {\n\t\t\terror = false\n\t\t})\n\t})\n</script>\n\n<div\n\tclass={[\n\t\t'flex aspect-square overflow-hidden ring-1 ring-surfaceContainerHigh contain-strict',\n\t\t!noFallbackBg && 'bg-surfaceContainerHighest',\n\t\tclassName,\n\t]}\n>\n\t{#if src && !error}\n\t\t<!-- biome-ignore lint/a11y/useAltText: false positive, alt exists -->\n\t\t<img\n\t\t\t{src}\n\t\t\t{alt}\n\t\t\tloading=\"eager\"\n\t\t\tclass=\"size-full object-cover\"\n\t\t\tdraggable=\"false\"\n\t\t\tonerror={() => {\n\t\t\t\terror = true\n\t\t\t}}\n\t\t\tonload={() => {\n\t\t\t\terror = false\n\t\t\t}}\n\t\t/>\n\t{:else if fallbackIcon !== false}\n\t\t<Icon type={fallbackIcon} class=\"m-auto size-2/3\" />\n\t{/if}\n\n\t{#if children}\n\t\t{@render children()}\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/lib/components/BackButton.svelte",
    "content": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport IconButton from './IconButton.svelte'\n\n\tinterface Props {\n\t\tclass?: ClassValue\n\t}\n\n\tconst { class: className }: Props = $props()\n\n\tconst canGoBack = () => {\n\t\tif (window.navigation !== undefined) {\n\t\t\treturn window.navigation.canGoBack\n\t\t}\n\n\t\t// This will not be a reliable check, but better than nothing\n\t\treturn window.history.length > 1\n\t}\n\n\tconst handleBackClick = () => {\n\t\tif (canGoBack()) {\n\t\t\twindow.history.back()\n\t\t} else {\n\t\t\tvoid goto('/library/tracks')\n\t\t}\n\t}\n</script>\n\n<IconButton tooltip={m.goBack()} icon=\"backArrow\" class={className} onclick={handleBackClick} />\n"
  },
  {
    "path": "src/lib/components/Button.svelte",
    "content": "<script module lang=\"ts\">\n\timport { ripple } from '../attachments/ripple.ts'\n\timport { tooltip } from '../attachments/tooltip.ts'\n\n\texport type AllowedButtonElement = 'button' | 'a'\n\texport type ButtonKind = 'filled' | 'toned' | 'outlined' | 'flat' | 'blank'\n\n\texport type ButtonHref<As extends AllowedButtonElement> = As extends 'a' ? string : never\n\n\texport interface ButtonProps<As extends AllowedButtonElement> {\n\t\tas?: As\n\t\tkind?: ButtonKind\n\t\ttype?: 'button' | 'submit' | 'reset'\n\t\ttarget?: string\n\t\tdisabled?: boolean\n\t\thref?: ButtonHref<As>\n\t\tclass?: ClassValue\n\t\ttabindex?: number\n\t\tariaLabel?: string\n\t\ttooltip?: string\n\t\tchildren?: Snippet\n\t\tonclick?: (event: MouseEvent) => void\n\t\tonpointerdown?: (event: PointerEvent) => void\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"As extends AllowedButtonElement = 'button'\">\n\tconst {\n\t\tas = 'button' as As,\n\t\tkind = 'filled',\n\t\tdisabled = false,\n\t\t// svelte-ignore state_referenced_locally possible false positive?\n\t\thref = (as === 'a' ? '' : undefined) as ButtonHref<As>,\n\t\ttype = 'button',\n\t\tchildren,\n\t\tariaLabel,\n\t\ttooltip: tooltipMessage,\n\t\t...restProps\n\t}: ButtonProps<As> = $props()\n\n\tconst KIND_CLASS_MAP = {\n\t\tfilled: 'filled-button',\n\t\ttoned: 'tonal-button',\n\t\toutlined: 'outlined-button',\n\t\tflat: 'flat-button',\n\t\tblank: '',\n\t} as const\n</script>\n\n<svelte:element\n\tthis={(disabled ? 'button' : as) as AllowedButtonElement}\n\t{@attach ripple({ stopPropagation: true })}\n\t{@attach tooltip(tooltipMessage)}\n\t{...restProps}\n\t{type}\n\taria-label={ariaLabel}\n\t{href}\n\tdisabled={disabled === true ? true : undefined}\n\tclass={[\n\t\t'interactable',\n\t\tKIND_CLASS_MAP[kind],\n\t\tkind !== 'blank' &&\n\t\t\t'base-button flex h-10 items-center justify-center gap-2 rounded-3xl px-6 text-label-lg transition-[outline-width] duration-150',\n\t\trestProps.class,\n\t]}\n>\n\t{#if children}\n\t\t{@render children()}\n\t{/if}\n</svelte:element>\n\n<style lang=\"postcss\">\n\t@reference '../../app.css';\n\n\t.filled-button {\n\t\tbackground: var(--color-primary);\n\t\tcolor: var(--color-onPrimary);\n\t}\n\n\t.tonal-button {\n\t\tbackground: var(--color-secondaryContainer);\n\t\tcolor: var(--color-onSecondaryContainer);\n\t}\n\n\t.outlined-button {\n\t\tcolor: var(--color-primary);\n\t\tborder: 1px solid var(--color-outline);\n\t}\n\n\t.flat-button {\n\t\tcolor: var(--color-primary);\n\t\tpadding-left: --spacing(3);\n\t\tpadding-right: --spacing(3);\n\t}\n\n\t.base-button[disabled] {\n\t\tcursor: default;\n\t\tbackground-color: --alpha(var(--color-onSurface) / 12%);\n\t\tborder-color: --alpha(var(--color-onSurface) / 38%);\n\t\tcolor: --alpha(var(--color-onSurface) / 38%);\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/FavoriteButton.svelte",
    "content": "<script lang=\"ts\">\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport { toggleFavoriteTrack } from '$lib/library/playlists-actions'\n\n\tinterface FavoriteButtonProps {\n\t\ttrackId: number\n\t\tfavorite: boolean\n\t\ttabindex?: number\n\t\tclass?: ClassValue\n\t}\n\n\tconst { trackId, favorite, tabindex, class: className }: FavoriteButtonProps = $props()\n\tconst clickHandler = async (e: MouseEvent) => {\n\t\te.stopPropagation()\n\n\t\tconst success = await toggleFavoriteTrack(favorite, trackId)\n\t\tif (!success) {\n\t\t\treturn\n\t\t}\n\n\t\tconst icon = (e.target as HTMLElement)?.querySelector('svg')\n\t\tif (!icon) {\n\t\t\treturn\n\t\t}\n\n\t\ticon.animate(\n\t\t\t{\n\t\t\t\ttransform: ['scale(1)', 'scale(0.6)', 'scale(1)'],\n\t\t\t},\n\t\t\t{\n\t\t\t\tduration: 400,\n\t\t\t\teasing: 'cubic-bezier(0.4, 0, 0.2, 1)',\n\t\t\t},\n\t\t)\n\t}\n</script>\n\n<IconButton\n\t{tabindex}\n\tclass={className}\n\ticon={favorite ? 'favorite' : 'favoriteOutline'}\n\ttooltip={favorite ? m.trackRemoveFromFavorites() : m.trackAddToFavorites()}\n\tonclick={clickHandler}\n/>\n"
  },
  {
    "path": "src/lib/components/Header.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { browser } from '$app/environment'\n\texport interface HeaderProps {\n\t\tchildren?: Snippet\n\t\ttitle?: string\n\t\tnoBackButton?: boolean\n\t\t/** @default 'fixed' */\n\t\tmode?: 'fixed' | 'sticky'\n\t\tclass?: (isElevated: boolean) => ClassValue\n\t}\n</script>\n\n<script lang=\"ts\">\n\timport BackButton from './BackButton.svelte'\n\n\tconst { children, title, noBackButton, mode = 'fixed', class: className }: HeaderProps = $props()\n\n\tconst isFixed = $derived(mode === 'fixed')\n\n\tlet scrollThresholdEl = $state<HTMLDivElement>()\n\tlet isScrolled = $state(false)\n\n\tif (browser) {\n\t\tconst io = new IntersectionObserver(\n\t\t\t([entry]) => {\n\t\t\t\tisScrolled = !entry?.isIntersecting\n\t\t\t},\n\t\t\t{ threshold: 0 },\n\t\t)\n\n\t\t$effect(() => {\n\t\t\tif (scrollThresholdEl) {\n\t\t\t\tio.observe(scrollThresholdEl)\n\t\t\t}\n\n\t\t\treturn () => {\n\t\t\t\tio.disconnect()\n\t\t\t}\n\t\t})\n\t}\n</script>\n\n<div bind:this={scrollThresholdEl} class=\"h-0 w-full\" inert></div>\n\n{#if isFixed}\n\t<div class=\"h-(--app-header-height) shrink-0\" aria-hidden=\"true\"></div>\n{/if}\n\n<header\n\tclass={[\n\t\t'ease-in-out inset-x-0 top-0 z-10 flex h-(--app-header-height) shrink-0 transition-[background-color] duration-200',\n\t\tisScrolled && 'bg-surfaceContainerHigh',\n\t\tisFixed ? 'fixed' : 'sticky',\n\t\tclassName?.(isScrolled),\n\t]}\n>\n\t<div\n\t\tclass=\"mx-auto flex w-full max-w-(--app-max-content-width) items-center justify-end gap-2 pr-2 pl-6\"\n\t>\n\t\t{#if !noBackButton}\n\t\t\t<BackButton class={[!title && 'mr-auto']} />\n\t\t{/if}\n\n\t\t{#if title}\n\t\t\t<div class=\"mr-auto text-title-lg\">{title}</div>\n\t\t{/if}\n\n\t\t{@render children?.()}\n\t</div>\n</header>\n"
  },
  {
    "path": "src/lib/components/IconButton.svelte",
    "content": "<script lang=\"ts\" module>\n\timport Button, { type AllowedButtonElement, type ButtonProps } from './Button.svelte'\n\timport Icon, { type IconType } from './icon/Icon.svelte'\n\n\tinterface IconButtonProps<As extends AllowedButtonElement> extends ButtonProps<As> {\n\t\ttooltip: string\n\t\ticon?: IconType\n\t\tchildren?: Snippet\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"As extends AllowedButtonElement = 'button'\">\n\tconst { icon, children, ...rest }: IconButtonProps<As> = $props()\n</script>\n\n<Button\n\t{...rest}\n\tkind=\"blank\"\n\tclass={[\n\t\t'flex size-11 shrink-0 items-center justify-center rounded-full',\n\t\trest.class,\n\t\trest.disabled && 'opacity-54',\n\t]}\n>\n\t{#if children}\n\t\t{@render children()}\n\t{:else if icon}\n\t\t<Icon type={icon} />\n\t{/if}\n</Button>\n"
  },
  {
    "path": "src/lib/components/ListDetailsLayout.svelte",
    "content": "<script lang=\"ts\" module>\n\timport ScrollContainer from './ScrollContainer.svelte'\n\n\texport type LayoutMode = 'both' | 'list' | 'details'\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\tid?: string\n\t\tmode: LayoutMode\n\t\tlist: Snippet<[LayoutMode]>\n\t\tdetails: Snippet<[LayoutMode]>\n\t\tclass?: ClassValue\n\t\tnoPlayerOverlayPadding?: boolean\n\t\tnoListStableGutter?: boolean\n\t}\n\n\tconst {\n\t\tid,\n\t\tmode,\n\t\tlist,\n\t\tdetails,\n\t\tclass: className,\n\t\tnoListStableGutter,\n\t\tnoPlayerOverlayPadding,\n\t}: Props = $props()\n\n\tlet listOffsetWidth = $state(0)\n\tlet isBothMode = $derived(mode === 'both')\n</script>\n\n<div {id} class={['flex! flex-col!', className]}>\n\t<div class=\"flex h-full grow\">\n\t\t{#if isBothMode}\n\t\t\t<ScrollContainer\n\t\t\t\tbind:offsetWidth={listOffsetWidth}\n\t\t\t\tclass={[\n\t\t\t\t\t'fixed top-0 flex max-h-dvh min-h-full shrink-0 flex-col overflow-y-auto overscroll-contain',\n\t\t\t\t\t!noPlayerOverlayPadding && 'pb-[calc(var(--bottom-overlay-height)+16px)]',\n\t\t\t\t\t!noListStableGutter && 'scrollbar-gutter-stable',\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t{@render list(mode)}\n\t\t\t</ScrollContainer>\n\t\t{/if}\n\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'flex w-full grow flex-col',\n\t\t\t\t!noPlayerOverlayPadding && 'pb-[calc(var(--bottom-overlay-height)+16px)]',\n\t\t\t]}\n\t\t\tstyle={isBothMode ? `padding-left: ${listOffsetWidth}px;` : undefined}\n\t\t>\n\t\t\t{#if isBothMode || mode === 'details'}\n\t\t\t\t{@render details(mode)}\n\t\t\t{:else if mode === 'list'}\n\t\t\t\t{@render list(mode)}\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/ListItem.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple.ts'\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\tstyle?: string\n\t\tclass?: ClassValue\n\t\tariaLabel: string\n\t\tariaRowIndex: number\n\t\ttabindex: number\n\t\tchildren: Snippet\n\t\tonclick?: (e: KeyboardEvent | MouseEvent) => void\n\t\tonpointerenter?: (e: PointerEvent) => void\n\t\toncontextmenu?: (e: MouseEvent) => void\n\t}\n\n\tconst {\n\t\tchildren,\n\t\tclass: className,\n\t\tstyle,\n\t\tariaLabel,\n\t\tariaRowIndex,\n\t\ttabindex = 0,\n\t\tonclick,\n\t\toncontextmenu,\n\t\tonpointerenter,\n\t}: Props = $props()\n\n\tconst clickHandler = (e: KeyboardEvent | MouseEvent) => onclick?.(e)\n</script>\n\n<div\n\t{@attach ripple()}\n\t{style}\n\t{tabindex}\n\tclass={[\n\t\tclassName,\n\t\t'flex cursor-pointer items-center overflow-hidden rounded-lg pr-2 pl-4 -outline-offset-2 contain-content hover:bg-onSurface/10',\n\t]}\n\trole=\"row\"\n\taria-label={ariaLabel}\n\taria-rowindex={ariaRowIndex}\n\tonclick={clickHandler}\n\t{onpointerenter}\n\tonkeydown={(e) => {\n\t\tif (e.key === 'Enter') {\n\t\t\tclickHandler(e)\n\t\t}\n\t}}\n\t{oncontextmenu}\n>\n\t{@render children()}\n</div>\n"
  },
  {
    "path": "src/lib/components/MenuButton.svelte",
    "content": "<script lang=\"ts\" module>\n\timport IconButton from './IconButton.svelte'\n\timport type { IconType } from './icon/icon-paths.server.ts'\n\timport type { MenuAlignment, MenuItem } from './menu/types.ts'\n\n\texport type { MenuItem }\n\n\texport type ListMenuFn = () => MenuItem[]\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\tclass?: ClassValue\n\t\tariaLabel?: string\n\t\ttooltip?: string\n\t\ttabindex?: number\n\t\talignment?: MenuAlignment\n\t\twidth?: number\n\t\ticon?: IconType\n\t\tmenuItems?: (() => MenuItem[]) | MenuItem[]\n\t}\n\n\tconst {\n\t\tclass: className,\n\t\tariaLabel,\n\t\ttooltip = m.moreOptions(),\n\t\ttabindex = 0,\n\t\tmenuItems,\n\t\talignment = { horizontal: 'right', vertical: 'top' },\n\t\twidth,\n\t\ticon = 'moreVertical',\n\t}: Props = $props()\n\n\tconst menu = useMenu()\n</script>\n\n{#if menuItems}\n\t<IconButton\n\t\t{ariaLabel}\n\t\t{tabindex}\n\t\t{icon}\n\t\t{tooltip}\n\t\tclass={className}\n\t\tonclick={(e) => {\n\t\t\te.stopPropagation()\n\n\t\t\tmenu.showFromEvent(e, typeof menuItems === 'function' ? menuItems() : menuItems, {\n\t\t\t\tanchor: true,\n\t\t\t\twidth,\n\t\t\t\tpreferredAlignment: alignment,\n\t\t\t})\n\t\t}}\n\t/>\n{/if}\n"
  },
  {
    "path": "src/lib/components/PlayerOverlay.svelte",
    "content": "<script lang=\"ts\">\n\timport { formatArtists, getItemLanguage } from '$lib/helpers/utils/text.ts'\n\timport Button from './Button.svelte'\n\timport Icon from './icon/Icon.svelte'\n\timport PlayerFavoriteButton from './player/buttons/PlayerFavoriteButton.svelte'\n\timport PlayNextButton from './player/buttons/PlayNextButton.svelte'\n\timport PlayToggleButton from './player/buttons/PlayToggleButton.svelte'\n\timport MainControls from './player/MainControls.svelte'\n\timport PlayerArtwork from './player/PlayerArtwork.svelte'\n\timport Timeline from './player/Timeline.svelte'\n\timport VolumeSlider from './player/VolumeSlider.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n\n\tconst mainStore = useMainStore()\n\tconst player = usePlayer()\n\n\tconst track = $derived(player.activeTrack)\n</script>\n\n<div\n\tid=\"mini-player\"\n\tclass={[\n\t\t'pointer-events-auto mx-auto w-full max-w-225 justify-between overflow-hidden rounded-2xl border border-primary/10 bg-secondaryContainer text-onSecondaryContainer contain-content view-name-[pl-card] sm:h-auto sm:rounded-3xl active-view-player:border-transparent',\n\t\tclassName,\n\t]}\n>\n\t<div class=\"flex size-full flex-col items-center justify-between gap-4 sm:px-4 sm:pt-2 sm:pb-4\">\n\t\t<Timeline class=\"max-sm:hidden\" />\n\t\t<div class=\"flex h-min w-full grow grid-cols-[1fr_max-content_1fr] items-center sm:grid\">\n\t\t\t<div class=\"flex grow items-center\">\n\t\t\t\t<Button\n\t\t\t\t\tas=\"a\"\n\t\t\t\t\thref=\"/player\"\n\t\t\t\t\tkind=\"blank\"\n\t\t\t\t\ttooltip={m.playerOpenFullPlayer()}\n\t\t\t\t\tclass=\"max-sm:rounded-r-4 group flex grow items-center rounded-lg pr-2 max-sm:p-2 sm:h-11 sm:max-w-45\"\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"relative -z-1 size-11 shrink-0 overflow-hidden rounded-lg bg-onSecondary active-view-player:view-name-[pl-artwork]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if track}\n\t\t\t\t\t\t\t<PlayerArtwork class=\"size-full\" />\n\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\ttype=\"chevronUp\"\n\t\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t\t'absolute inset-0 m-auto shrink-0 active-view-player:view-name-[pl-chevron-up]',\n\t\t\t\t\t\t\t\ttrack &&\n\t\t\t\t\t\t\t\t\t'scale-0 rounded-full bg-tertiary text-onTertiary transition-[transform,opacity] duration-200 [.group:hover_&]:scale-100',\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{#if track}\n\t\t\t\t\t\t<div class=\"mr-1 ml-4 grid min-w-0\" lang={getItemLanguage(track.language)}>\n\t\t\t\t\t\t\t<div class=\"truncate text-body-md\">\n\t\t\t\t\t\t\t\t{track.name}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"truncate text-body-sm\">{formatArtists(track.artists)}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t</Button>\n\n\t\t\t\t<PlayerFavoriteButton />\n\t\t\t</div>\n\n\t\t\t<div class=\"ml-auto flex gap-2 pr-2 sm:hidden\">\n\t\t\t\t<PlayToggleButton />\n\n\t\t\t\t<PlayNextButton class=\"max-xss:hidden\" />\n\t\t\t</div>\n\n\t\t\t<MainControls class=\"max-sm:hidden\" />\n\n\t\t\t<div class=\"ml-auto flex items-center gap-2 pr-2 max-sm:hidden\">\n\t\t\t\t{#if mainStore.volumeSliderEnabled}\n\t\t\t\t\t<VolumeSlider />\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>\n\n<style lang=\"postcss\">\n\t@reference '../../app.css';\n\n\t.controls {\n\t\tgrid-template-columns: 1fr max-content 1fr;\n\t}\n\n\t::view-transition-old(pl-chevron-up) {\n\t\tdisplay: none;\n\t}\n\n\t@keyframes -global-view-pl-chevron-up-fade-in {\n\t\tfrom {\n\t\t\topacity: 0;\n\t\t\ttransform: scale(0);\n\t\t}\n\t}\n\n\t::view-transition-new(pl-chevron-up) {\n\t\tanimation: view-pl-chevron-up-fade-in 125ms 225ms linear backwards;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/ScrollContainer.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { getContext, setContext } from 'svelte'\n\n\ttype ScrollTargetElement = Element | Window | null\n\n\tconst contextKey = Symbol('scroll-target')\n\n\texport const useScrollTarget = () => {\n\t\tconst nodeGetter = getContext<() => ScrollTargetElement>(contextKey)\n\n\t\treturn {\n\t\t\tget current(): Element | Window {\n\t\t\t\tconst node = nodeGetter?.()\n\n\t\t\t\treturn node ?? window\n\t\t\t},\n\t\t}\n\t}\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\tclass?: ClassValue\n\t\toffsetWidth?: number\n\t\tchildren: Snippet\n\t}\n\n\tlet { class: className, offsetWidth = $bindable(), children }: Props = $props()\n\n\tlet scrollTarget = $state<ScrollTargetElement>(null)\n\n\tsetContext(contextKey, () => scrollTarget)\n</script>\n\n<div bind:this={scrollTarget} bind:offsetWidth class={['overscroll-contain', className]}>\n\t{@render children()}\n</div>\n"
  },
  {
    "path": "src/lib/components/Select.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport Icon from './icon/Icon.svelte'\n\n\texport interface SelectProps<T, Key extends keyof T, LabelKey extends keyof T> {\n\t\titems: readonly T[]\n\t\tkey: Key\n\t\tlabelKey: LabelKey\n\t\tplaceholder?: string\n\t\tselected?: T[Key]\n\t\tclass?: ClassValue\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"T, const Key extends keyof T, const LabelKey extends keyof T\">\n\tlet {\n\t\titems,\n\t\tkey,\n\t\tlabelKey,\n\t\tplaceholder,\n\t\tselected = $bindable(),\n\t\tclass: className,\n\t}: SelectProps<T, Key, LabelKey> = $props()\n\n\tconst selectedItem = $derived(items.find((item) => item[key] === selected))\n\n\tconst uid = $props.id()\n\tconst anchorName = `--select-anchor-${uid}`\n\n\tconst popupId = `select-popup-${uid}`\n\tlet popup = $state<HTMLDivElement | null>(null)\n\tlet isOpen = $state(false)\n</script>\n\n<button\n\t{@attach ripple()}\n\tstyle={`anchor-name: ${anchorName};`}\n\tclass={[\n\t\t'select-anchor relative flex h-10 cursor-pointer appearance-none items-center gap-2 truncate overflow-hidden rounded-sm border border-outlineVariant pr-2 pl-4 transition-[outline-width] duration-150',\n\t\tclassName,\n\t]}\n\trole=\"combobox\"\n\taria-controls={popupId}\n\taria-owns={popupId}\n\taria-expanded={isOpen}\n\tpopovertarget={popupId}\n\ttype=\"button\"\n>\n\t<div class=\"truncate\">\n\t\t{#if selectedItem}\n\t\t\t{selectedItem[labelKey]}\n\t\t{:else}\n\t\t\t{placeholder}\n\t\t{/if}\n\t</div>\n\n\t<Icon type=\"menuDown\" class=\"ml-auto size-5\" />\n</button>\n\n<div\n\tbind:this={popup}\n\tid={popupId}\n\taria-orientation=\"vertical\"\n\trole=\"listbox\"\n\tpopover=\"auto\"\n\tstyle={`position-anchor: ${anchorName};`}\n\tclass=\"select-popup m-0 hidden flex-col rounded-sm bg-surfaceContainerHighest px-0 py-2 shadow-xl open:flex\"\n\tontoggle={(e) => {\n\t\tisOpen = e.newState === 'open'\n\t}}\n>\n\t{#each items as item (item[key])}\n\t\t<button\n\t\t\t{@attach ripple()}\n\t\t\trole=\"option\"\n\t\t\taria-selected={item[key] === selected}\n\t\t\ttype=\"button\"\n\t\t\tclass={[\n\t\t\t\t'interactable flex h-10 w-full cursor-pointer items-center overflow-hidden px-4 -outline-offset-2',\n\t\t\t\titem[key] === selected && 'text-primary',\n\t\t\t]}\n\t\t\tonclick={() => {\n\t\t\t\tselected = item[key]\n\t\t\t\tpopup?.hidePopover()\n\t\t\t}}\n\t\t>\n\t\t\t{item[labelKey]}\n\t\t</button>\n\t{/each}\n</div>\n\n<style>\n\t.select-popup {\n\t\tposition-area: bottom center;\n\t\tposition-try-fallbacks: flip-block;\n\t\twidth: anchor-size(width);\n\t\ttransition-property: opacity, overlay, display;\n\t\ttransition-duration: 0.2s;\n\t\ttransition-behavior: allow-discrete;\n\t\topacity: 0;\n\t\t&:popover-open {\n\t\t\topacity: 1;\n\t\t}\n\t}\n\n\t@starting-style {\n\t\t[popover]:popover-open {\n\t\t\topacity: 0;\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/Separator.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tvertical?: boolean\n\t\tclass?: ClassValue\n\t}\n\n\tconst { vertical, class: className }: Props = $props()\n</script>\n\n<!-- biome-ignore-start lint/a11y/useAriaPropsForRole: false positive -->\n<div\n\trole=\"separator\"\n\taria-orientation={vertical ? 'vertical' : 'horizontal'}\n\tclass={[\n\t\tclassName,\n\t\t'shrink-0 self-stretch border-outlineVariant',\n\t\tvertical ? 'w-0 border-r' : 'h-0 border-b',\n\t]}\n></div>\n<!-- biome-ignore-end lint/a11y/useAriaPropsForRole: false positive -->\n"
  },
  {
    "path": "src/lib/components/Slider.svelte",
    "content": "<script lang=\"ts\">\n\timport { clamp } from '$lib/helpers/utils/clamp.ts'\n\n\tinterface Props {\n\t\tmin?: number\n\t\tmax?: number\n\t\tstep?: number\n\t\tvalue: number\n\t\tdisabled?: boolean\n\t\tvertical?: boolean\n\t\tonSeekStart?: () => void\n\t\tonSeekEnd?: () => void\n\t}\n\n\tlet {\n\t\tmin = 0,\n\t\tmax = 100,\n\t\tstep,\n\t\tvalue = $bindable(0),\n\t\tdisabled,\n\t\tvertical = false,\n\t\tonSeekStart,\n\t\tonSeekEnd,\n\t}: Props = $props()\n\n\tconst progressPercentage = $derived.by(() => {\n\t\tconst percentage = ((value - min) * 100) / (max - min)\n\t\tconst percentageSafe = Number.isFinite(percentage) ? percentage : 0\n\n\t\treturn percentageSafe\n\t})\n\n\tlet trackSize = $state(0)\n\n\tconst getValueFromPercentage = (percentage: number, rangeMin: number, rangeMax: number) => {\n\t\tconst newValue = (percentage / 100) * (rangeMax - rangeMin) + rangeMin\n\n\t\treturn newValue\n\t}\n\n\tinterface TrackBorderOptions {\n\t\ttrackStart: number\n\t\ttrackEnd: number\n\t\troundedStart: number\n\t\troundedEnd: number\n\t}\n\n\tconst getPercentageFromValue = (value: number, rangeMin: number, rangeMax: number) =>\n\t\t((value - rangeMin) / (rangeMax - rangeMin)) * 100\n\n\tconst getTrackRange = (currentTrackSize: number, options: TrackBorderOptions) => {\n\t\tconst sizePercentage = getPercentageFromValue(\n\t\t\tcurrentTrackSize,\n\t\t\toptions.trackStart,\n\t\t\toptions.trackEnd,\n\t\t)\n\n\t\tconst borderValue = getValueFromPercentage(\n\t\t\tsizePercentage,\n\t\t\toptions.roundedStart,\n\t\t\toptions.roundedEnd,\n\t\t)\n\n\t\treturn clamp(Math.round(borderValue), options.roundedStart, options.roundedEnd)\n\t}\n\n\tconst getBarBorder = () => {\n\t\tconst currentTrackSize = getValueFromPercentage(progressPercentage, 0, trackSize)\n\n\t\tconst start = getTrackRange(currentTrackSize, {\n\t\t\ttrackStart: 0,\n\t\t\ttrackEnd: 36,\n\t\t\troundedStart: 2,\n\t\t\troundedEnd: 8,\n\t\t})\n\n\t\tconst end = getTrackRange(currentTrackSize, {\n\t\t\ttrackStart: trackSize,\n\t\t\ttrackEnd: trackSize - 36,\n\t\t\troundedStart: 2,\n\t\t\troundedEnd: 8,\n\t\t})\n\n\t\tif (vertical) {\n\t\t\treturn `border-radius: ${end}px ${end}px ${start}px ${start}px;`\n\t\t}\n\n\t\treturn `border-radius: ${start}px ${end}px ${end}px ${start}px;`\n\t}\n\n\tconst getTransform = (calc = '') => {\n\t\tif (vertical) {\n\t\t\treturn `transform: translateY(calc(-${progressPercentage}% ${calc}));`\n\t\t}\n\n\t\treturn `transform: translateX(calc(${progressPercentage}% ${calc}));`\n\t}\n</script>\n\n<div\n\tclass={['slider relative flex overflow-hidden select-none', vertical ? 'h-full' : 'w-full']}\n\tbind:clientWidth={\n\t\tnull,\n\t\t(width: number) => {\n\t\t\tif (!vertical) {\n\t\t\t\ttrackSize = width\n\t\t\t}\n\t\t}\n\t}\n\tbind:clientHeight={\n\t\tnull,\n\t\t(height: number) => {\n\t\t\tif (vertical) {\n\t\t\t\ttrackSize = height\n\t\t\t}\n\t\t}\n\t}\n>\n\t<input\n\t\ttype=\"range\"\n\t\tbind:value\n\t\t{disabled}\n\t\t{min}\n\t\t{max}\n\t\t{step}\n\t\tclass={[\n\t\t\t'grow appearance-none opacity-0 disabled:cursor-auto',\n\t\t\tvertical ? 'vertical-input h-full w-11' : 'horizontal-input h-11 w-full',\n\t\t]}\n\t\tstyle={vertical ? 'writing-mode: vertical-lr; direction: rtl;' : ''}\n\t\tonpointerdown={() => {\n\t\t\tonSeekStart?.()\n\t\t}}\n\t\tonpointerup={() => {\n\t\t\tonSeekEnd?.()\n\t\t}}\n\t\tontouchstart={() => {\n\t\t\tonSeekStart?.()\n\t\t}}\n\t\tontouchend={() => {\n\t\t\tonSeekEnd?.()\n\t\t}}\n\t/>\n\n\t<div\n\t\tclass={[\n\t\t\t'pointer-events-none absolute',\n\t\t\tvertical\n\t\t\t\t? 'bottom-0 left-0 mt-2 flex h-(--slider-size) w-full flex-col justify-end'\n\t\t\t\t: 'top-0 left-0 mr-2 h-full w-(--slider-size)',\n\t\t]}\n\t\tstyle={getTransform()}\n\t>\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'thumb rounded-lg transition-transform',\n\t\t\t\tvertical ? 'h-1 w-full' : 'h-full w-1',\n\t\t\t\tdisabled ? 'bg-onSurface/38' : 'bg-primary',\n\t\t\t]}\n\t\t></div>\n\t</div>\n\n\t<div\n\t\tclass={[\n\t\t\t'pointer-events-none absolute inset-0 self-end overflow-clip transition-[border-radius] duration-50 contain-strict',\n\t\t\tvertical ? 'mx-auto h-(--slider-size) w-4' : 'my-auto h-4 w-(--slider-size)',\n\t\t]}\n\t\tstyle={getBarBorder()}\n\t>\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'absolute',\n\t\t\t\tvertical\n\t\t\t\t\t? 'rounded-t-0.5 inset-x-0 -bottom-full mx-auto h-full w-4'\n\t\t\t\t\t: 'rounded-r-0.5 inset-y-0 -left-full my-auto h-4 w-full',\n\t\t\t\tdisabled ? 'bg-onSurface/38' : 'bg-primary',\n\t\t\t]}\n\t\t\tstyle={getTransform(vertical ? '+ 6px' : '- 6px')}\n\t\t></div>\n\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'absolute size-full',\n\t\t\t\tvertical ? 'rounded-b-0.5 bottom-0 left-0' : 'rounded-l-0.5 top-0 left-0',\n\t\t\t\tdisabled ? 'bg-onSurface/12' : 'bg-primary/30',\n\t\t\t]}\n\t\t\tstyle={getTransform(vertical ? '- 10px' : '+ 10px')}\n\t\t></div>\n\t</div>\n</div>\n\n<style lang=\"postcss\">\n\t@reference '../../app.css';\n\n\t.slider {\n\t\t--slider-size: calc(100% - --spacing(1));\n\t}\n\n\t.horizontal-input:not(:disabled):is(:active, :focus-visible) ~ div > .thumb {\n\t\ttransform: scaleX(0.5);\n\t}\n\t.vertical-input:not(:disabled):is(:active, :focus-visible) ~ div > .thumb {\n\t\ttransform: scaleY(0.5);\n\t}\n\n\tinput::-webkit-slider-thumb {\n\t\t-webkit-appearance: none;\n\t\tcursor: pointer;\n\t\tbackground-color: red;\n\t}\n\n\tinput:disabled::-webkit-slider-thumb {\n\t\tcursor: auto;\n\t}\n\n\t.horizontal-input::-webkit-slider-thumb {\n\t\theight: --spacing(11);\n\t\twidth: --spacing(4);\n\t}\n\n\t.vertical-input::-webkit-slider-thumb {\n\t\theight: --spacing(4);\n\t\twidth: --spacing(11);\n\t}\n\n\tinput::-moz-range-thumb {\n\t\tcursor: pointer;\n\t}\n\n\tinput:disabled::-moz-range-thumb {\n\t\tcursor: auto;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/Spinner.svelte",
    "content": "<script lang=\"ts\">\n\t// Spinner from https://codepen.io/mrrocks/pen/EiplA\n\n\tinterface Props {\n\t\tclass?: ClassValue\n\t}\n\n\tconst { class: className }: Props = $props()\n</script>\n\n<svg class={['spinner', className]} fill=\"transparent\" width=\"40\" height=\"40\" viewBox=\"0 0 66 66\">\n\t<circle class=\"path\" cx=\"33\" cy=\"33\" r=\"30\" />\n</svg>\n\n<style>\n\t.spinner {\n\t\t--spinner-duration: 1.4s;\n\t\t--spinner-offset: 187;\n\t\tanimation: rotate var(--spinner-duration) linear infinite;\n\t\tcolor: currentcolor;\n\t}\n\n\t.path {\n\t\tstroke: currentcolor;\n\t\tstroke-width: 6;\n\t\tstroke-linecap: round;\n\t\tstroke-dasharray: var(--spinner-offset);\n\t\tstroke-dashoffset: 0;\n\t\ttransform-origin: center;\n\t\tanimation: dash var(--spinner-duration) ease-in-out infinite;\n\t}\n\n\t@keyframes rotate {\n\t\t0% {\n\t\t\ttransform: rotate(0deg);\n\t\t}\n\t\t100% {\n\t\t\ttransform: rotate(270deg);\n\t\t}\n\t}\n\n\t@keyframes dash {\n\t\t0% {\n\t\t\tstroke-dashoffset: var(--spinner-offset);\n\t\t}\n\t\t50% {\n\t\t\tstroke-dashoffset: 46.75;\n\t\t\ttransform: rotate(135deg);\n\t\t}\n\t\t100% {\n\t\t\tstroke-dashoffset: var(--spinner-offset);\n\t\t\ttransform: rotate(450deg);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/Switch.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tchecked: boolean\n\t}\n\n\tlet { checked = $bindable(false) }: Props = $props()\n\n\tconst toggle = () => {\n\t\tchecked = !checked\n\t}\n</script>\n\n<div\n\tclass={[\n\t\t'flex h-8 w-13 shrink-0 cursor-pointer items-center rounded-4xl border-2 outline-offset-2 transition-all duration-150',\n\t\tchecked ? 'border-transparent bg-primary' : 'border-outline bg-surfaceContainerHigh',\n\t]}\n\ttabindex=\"0\"\n\trole=\"switch\"\n\taria-checked={checked}\n\tonclick={toggle}\n\tonkeydown={(e) => {\n\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\te.preventDefault()\n\t\t\ttoggle()\n\t\t}\n\t}}\n>\n\t<input type=\"checkbox\" bind:checked class=\"hidden\" />\n\t<div\n\t\tclass={[\n\t\t\t'ml-1.5 h-4 w-4 rounded-full transition-all duration-150',\n\t\t\tchecked ? 'translate-x-5 scale-150 bg-onPrimary' : 'bg-outline',\n\t\t]}\n\t></div>\n</div>\n"
  },
  {
    "path": "src/lib/components/Tabs.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple'\n\n\tinterface Props<T> {\n\t\tselectedIndex: number\n\t\titems: readonly T[]\n\t\tonchange: (item: T, index: number) => void\n\t\ttext: Snippet<[T]>\n\t\tclass?: ClassValue\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"T\">\n\tconst { selectedIndex, items, onchange, text, class: className }: Props<T> = $props()\n</script>\n\n<div\n\tstyle=\"grid-template-columns: repeat({items.length}, minmax(0, 1fr))\"\n\tclass={['grid gap-1 rounded-full bg-surfaceContainerHighest p-1', className]}\n\trole=\"tablist\"\n>\n\t<span\n\t\tinert\n\t\tstyle=\"transform: translateX(calc((100% + var(--spacing)) * {selectedIndex}));\"\n\t\tclass=\"col-1 row-1 rounded-full bg-secondaryContainer transition-transform duration-150\"\n\t></span>\n\t{#each items as item, index}\n\t\t<button\n\t\t\t{@attach ripple()}\n\t\t\ttype=\"button\"\n\t\t\tstyle=\"grid-area: 1/ {index + 1};\"\n\t\t\tclass=\"relative min-w-20 cursor-pointer overflow-clip rounded-full px-4 py-2\"\n\t\t\taria-selected={index === selectedIndex}\n\t\t\trole=\"tab\"\n\t\t\tonclick={() => onchange(item, index)}\n\t\t>\n\t\t\t{@render text(item)}\n\t\t</button>\n\t{/each}\n</div>\n"
  },
  {
    "path": "src/lib/components/TextField.svelte",
    "content": "<script lang=\"ts\">\n\tinterface TextFieldProps {\n\t\tvalue?: string\n\t\tname: string\n\t\ttype?: 'text'\n\t\tplaceholder?: string\n\t\tminLength?: number\n\t\tmaxLength?: number\n\t\trequired?: boolean\n\t\tclass?: ClassValue\n\t}\n\n\tlet {\n\t\tname,\n\t\tvalue = $bindable(''),\n\t\ttype = 'text',\n\t\tplaceholder,\n\t\tminLength,\n\t\tmaxLength,\n\t\trequired,\n\t\tclass: className,\n\t}: TextFieldProps = $props()\n\n\tconst id = $props.id()\n\n\tconst validationIssue = $derived.by(() => {\n\t\tconst valueLength = value.length\n\t\tif (required && valueLength < 1) {\n\t\t\treturn m.validationRequired()\n\t\t}\n\n\t\tif (minLength !== undefined && valueLength < minLength) {\n\t\t\treturn m.validationMinLength({ min: minLength })\n\t\t}\n\n\t\tif (maxLength !== undefined && valueLength > maxLength) {\n\t\t\treturn m.validationMaxLength({ max: maxLength })\n\t\t}\n\n\t\treturn null\n\t})\n</script>\n\n<div class={[className, 'text-field-container']}>\n\t<div\n\t\tclass=\"flex h-14 flex-col rounded-md border border-outline p-px text-onSurface focus-within:border-2 focus-within:border-primary focus-within:p-0 [&:has(input:user-invalid)]:border-error\"\n\t>\n\t\t<input\n\t\t\tbind:value\n\t\t\t{name}\n\t\t\t{id}\n\t\t\t{type}\n\t\t\t{required}\n\t\t\tclass=\"w-full grow appearance-none border-none bg-transparent px-3.5 outline-none placeholder:text-onSurfaceVariant\"\n\t\t\t{placeholder}\n\t\t\t{@attach (input) => {\n\t\t\t\tinput.setCustomValidity(validationIssue ? ' ' : '')\n\t\t\t}}\n\t\t/>\n\t</div>\n\t<div class=\"text-field-error mt-1 hidden px-4 text-body-sm text-error\">\n\t\t{validationIssue ?? ''}\n\t</div>\n</div>\n\n<style>\n\t.text-field-container:has(input:user-invalid) .text-field-error {\n\t\tdisplay: block;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/VirtualContainer.svelte",
    "content": "<script lang=\"ts\">\n\timport {\n\t\telementScroll,\n\t\tobserveElementOffset,\n\t\tobserveElementRect,\n\t\tobserveWindowOffset,\n\t\tobserveWindowRect,\n\t\ttype Range,\n\t\ttype VirtualItem,\n\t\ttype VirtualizerOptions,\n\t\twindowScroll,\n\t} from '@tanstack/virtual-core'\n\timport { doesElementHasFocus, findFocusedElement } from '$lib/helpers/focus.ts'\n\timport { createVirtualizerBase } from '$lib/helpers/virtualizer.svelte.ts'\n\timport { useScrollTarget } from './ScrollContainer.svelte'\n\n\tinterface Props {\n\t\tcount: number\n\t\tlanes?: number\n\t\tsize: number\n\t\tgap?: number\n\t\tforceRenderIndexes?: readonly number[]\n\t\toffsetWidth?: number\n\t\tkey: (index: number) => string | number\n\t\tchildren: Snippet<[VirtualItem]>\n\t}\n\n\tlet {\n\t\tcount,\n\t\tlanes = 1,\n\t\tgap = 0,\n\t\tsize: itemSize,\n\t\tforceRenderIndexes = [],\n\t\tkey,\n\t\tchildren,\n\t\toffsetWidth = $bindable(0),\n\t}: Props = $props()\n\n\tconst scrollTarget = useScrollTarget()\n\n\ttype VirtualizerTargetOptions<E extends Window | Element> = Pick<\n\t\tVirtualizerOptions<E, Element>,\n\t\t| 'getScrollElement'\n\t\t| 'observeElementRect'\n\t\t| 'observeElementOffset'\n\t\t| 'scrollToFn'\n\t\t| 'initialOffset'\n\t>\n\n\tconst scrollTargetOptions = $derived.by(() => {\n\t\tconst target = scrollTarget.current\n\n\t\tif (target instanceof Window) {\n\t\t\tconst options: VirtualizerTargetOptions<Window> = {\n\t\t\t\tgetScrollElement: () => target,\n\t\t\t\tobserveElementRect: observeWindowRect,\n\t\t\t\tobserveElementOffset: observeWindowOffset,\n\t\t\t\tscrollToFn: windowScroll,\n\t\t\t\tinitialOffset: () => window.scrollY,\n\t\t\t}\n\n\t\t\treturn options\n\t\t}\n\n\t\tconst options: VirtualizerTargetOptions<Element> = {\n\t\t\tgetScrollElement: () => target,\n\t\t\tobserveElementRect,\n\t\t\tobserveElementOffset,\n\t\t\tscrollToFn: elementScroll,\n\t\t}\n\n\t\treturn options\n\t})\n\n\tconst rangeExtractor = (range: Range) =>\n\t\t// We untrack because when focusIndex changes it forces virtualizer deps to change\n\t\t// which is not needed here.\n\t\tuntrack(() => {\n\t\t\tconst start = Math.max(range.startIndex - range.overscan, 0)\n\t\t\tconst initialEnd = range.endIndex + range.overscan\n\n\t\t\tconst arr = []\n\t\t\tif (focusIndex !== -1 && focusIndex < start) {\n\t\t\t\tarr.push(focusIndex)\n\t\t\t}\n\n\t\t\tconst end = Math.min(initialEnd, range.count - 1)\n\t\t\tfor (let i = start; i <= end; i += 1) {\n\t\t\t\tarr.push(i)\n\t\t\t}\n\n\t\t\tif (focusIndex !== -1 && focusIndex > initialEnd) {\n\t\t\t\tarr.push(focusIndex)\n\t\t\t}\n\n\t\t\tfor (const index of forceRenderIndexes) {\n\t\t\t\tif (index < 0 || index >= range.count || arr.includes(index)) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tarr.push(index)\n\t\t\t}\n\n\t\t\treturn arr\n\t\t})\n\n\tconst getVirtualizerOptions = () => {\n\t\tconst options: VirtualizerOptions<Window | Element, Element> = {\n\t\t\t// narrowing window/element specific types is difficult so we just cast here\n\t\t\t...(scrollTargetOptions as VirtualizerTargetOptions<Window | Element>),\n\t\t\tcount,\n\t\t\tlanes,\n\t\t\testimateSize: () => itemSize,\n\t\t\trangeExtractor,\n\t\t\toverscan: 10,\n\t\t}\n\n\t\treturn options\n\t}\n\n\tconst virtualizer = createVirtualizerBase(getVirtualizerOptions)\n\n\tlet focusIndex = $state(-1)\n\n\tlet container = $state<HTMLDivElement>()\n\n\tconst findRow = (index: number) => {\n\t\tconst el = container?.querySelector(`[aria-rowindex=\"${index}\"]`)\n\t\tif (el instanceof HTMLElement) {\n\t\t\treturn el\n\t\t}\n\n\t\treturn null\n\t}\n\n\tconst findCurrentFocusedRow = () => {\n\t\tconst index = container ? Number(findFocusedElement(container)?.ariaRowIndex) : -1\n\n\t\treturn Number.isNaN(index) ? -1 : index\n\t}\n\n\tconst keydownHandler = (e: KeyboardEvent) => {\n\t\tlet directionDown: boolean | undefined\n\t\tif (e.key === 'ArrowDown') {\n\t\t\tdirectionDown = true\n\t\t} else if (e.key === 'ArrowUp') {\n\t\t\tdirectionDown = false\n\t\t}\n\n\t\tif (directionDown === undefined) {\n\t\t\treturn\n\t\t}\n\n\t\te.preventDefault()\n\n\t\tif (container && doesElementHasFocus(container)) {\n\t\t\tvirtualizer.scrollToIndex(0, {\n\t\t\t\tbehavior: 'smooth',\n\t\t\t})\n\t\t\t// TODO. Should somehow await for scroll to finish.\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tfindRow(0)?.focus()\n\t\t\t})\n\n\t\t\treturn\n\t\t}\n\n\t\tconst increment = directionDown ? 1 : -1\n\t\tconst currentIndex = findCurrentFocusedRow()\n\n\t\tconst nextIndex = currentIndex + increment\n\t\tif (nextIndex >= 0 && nextIndex < count) {\n\t\t\tvirtualizer.scrollToIndex(currentIndex, {\n\t\t\t\tbehavior: 'smooth',\n\t\t\t})\n\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tfindRow(nextIndex)?.focus()\n\t\t\t})\n\t\t}\n\t}\n\n\tconst focusinHandler = () => {\n\t\tconst index = findCurrentFocusedRow()\n\t\tif (index !== -1) {\n\t\t\tfocusIndex = index\n\t\t}\n\t}\n\n\tconst focusoutHandler = () => {\n\t\tqueueMicrotask(() => {\n\t\t\tconst index = findCurrentFocusedRow()\n\t\t\tif (index === -1) {\n\t\t\t\tfocusIndex = -1\n\t\t\t}\n\t\t})\n\t}\n</script>\n\n{#if count === 0}\n\t<div class=\"m-auto h-max w-max self-center justify-self-center text-center\">\n\t\t{m.noItemsToDisplay()}\n\t</div>\n{:else}\n\t<div\n\t\tbind:this={container}\n\t\tbind:offsetWidth\n\t\trole=\"grid\"\n\t\taria-rowcount={count}\n\t\tstyle:height={`${virtualizer.getTotalSize() - gap}px`}\n\t\tclass=\"@container relative w-full rounded-lg -outline-offset-2 contain-strict\"\n\t\ttabindex=\"0\"\n\t\tonfocusin={focusinHandler}\n\t\tonfocusout={focusoutHandler}\n\t\tonkeydown={keydownHandler}\n\t>\n\t\t{#each virtualizer.getVirtualItems() as virtualItem (key(virtualItem.index))}\n\t\t\t{@render children(virtualItem)}\n\t\t{/each}\n\t</div>\n{/if}\n"
  },
  {
    "path": "src/lib/components/WrapTranslation.svelte",
    "content": "<script lang=\"ts\" generics=\"Params extends Record<string, unknown>\">\n\ttype Props = {\n\t\t[K in keyof Params]: Snippet\n\t} & {\n\t\tmessageFn: (value: Params) => string\n\t}\n\n\tconst { messageFn, ...props }: Props = $props()\n\n\tconst partMarker = '__PART_MARKER__'\n\tconst valueMarker = '__VALUE_MARKER__'\n\n\tconst parts = $derived.by(() => {\n\t\tconst paramsKeys = Object.keys(props)\n\n\t\tconst placeholdersEntries = paramsKeys.map(\n\t\t\t(key) => [key, `${partMarker}${valueMarker}${key}${partMarker}`] as const,\n\t\t)\n\n\t\tconst placeholderParams = Object.fromEntries(placeholdersEntries) as Params\n\t\tconst message = messageFn(placeholderParams)\n\n\t\treturn message.split(partMarker)\n\t})\n</script>\n\n<div>\n\t{#each parts as part}\n\t\t{#if part.startsWith(valueMarker)}\n\t\t\t{@render props[part.replaceAll(valueMarker, '')]?.()}\n\t\t{:else}\n\t\t\t{part}\n\t\t{/if}\n\t{/each}\n</div>\n"
  },
  {
    "path": "src/lib/components/animated-icons/PlayPauseIcon.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tplaying?: boolean\n\t}\n\n\tconst { playing = false }: Props = $props()\n</script>\n\n<div class={['play-icon relative z-1 size-6', playing && 'playing rotate-90']}>\n\t<div class=\"play-bar\"></div>\n\t<div class=\"play-bar flip-y\"></div>\n</div>\n\n<style>\n\t.play-icon {\n\t\ttransition: rotate 0.2s ease-out;\n\t}\n\n\t.play-bar {\n\t\tbackground: currentcolor;\n\t\theight: 50%;\n\t\tclip-path: polygon(32% 40%, 82% 102%, 82% 102%, 32% 102%);\n\t\ttransition: clip-path 0.2s ease-out;\n\t\t.playing & {\n\t\t\tclip-path: polygon(22% 50%, 80% 50%, 80% 84%, 22% 84%);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/animated-icons/PlayPreviousNextIcon.svelte",
    "content": "<script lang=\"ts\">\n\timport { on } from 'svelte/events'\n\timport { wait } from '$lib/helpers/utils/wait.ts'\n\n\tinterface Props {\n\t\ttype: 'next' | 'previous'\n\t}\n\n\tconst { type }: Props = $props()\n\n\tconst flipIcon = $derived(type === 'previous')\n\n\tlet isAnimating = $state(false)\n\n\tconst action = (target: HTMLDivElement) => {\n\t\tlet button = target.parentElement\n\n\t\twhile (button) {\n\t\t\tif (button.tagName === 'BUTTON') {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tbutton = button.parentElement\n\t\t}\n\n\t\tinvariant(button, 'No button found')\n\n\t\tconst cleanup = on(button, 'click', async () => {\n\t\t\tif ((button as HTMLButtonElement).disabled) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tisAnimating = true\n\t\t\tawait wait(200)\n\t\t\tisAnimating = false\n\t\t})\n\n\t\treturn cleanup\n\t}\n</script>\n\n<div\n\tclass={[flipIcon && 'flip-x', 'grid']}\n\tdata-icon-animating={isAnimating ? '' : undefined}\n\t{@attach action}\n>\n\t<!-- Cannot add clip on svg itself because of Safari bug  -->\n\t<div class=\"icon-clip stack-in-grid\">\n\t\t<svg class=\"size-6 fill-current\" viewBox=\"0 0 24 24\">\n\t\t\t<path class=\"skip-top\" d=\"M 6,18 14.5,12 6,6 M 8,9.86 11.03,12 8,14.14\" />\n\t\t\t<path class=\"skip-bottom invisible\" d=\"M 6,18 14.5,12 6,6 M 8,9.86 11.03,12 8,14.14\" />\n\t\t</svg>\n\t</div>\n\t<svg class=\"size-6 fill-current stack-in-grid\" viewBox=\"0 0 24 24\">\n\t\t<path d=\"M16,6L16,18L18,18L18,6L16,6Z\" />\n\t</svg>\n</div>\n\n<style>\n\t.icon-clip {\n\t\tclip-path: inset(0 8px 0 6px);\n\t}\n\n\t@keyframes skipTopAni {\n\t\tfrom {\n\t\t\ttransform: translate(0px, 0px);\n\t\t}\n\t\tto {\n\t\t\ttransform: translate(10px, 0px);\n\t\t}\n\t}\n\n\t[data-icon-animating] .skip-top {\n\t\tanimation: skipTopAni 0.2s ease-out;\n\t}\n\n\t@keyframes skipBottomAni {\n\t\tfrom {\n\t\t\ttransform: translate(-10px, 0px);\n\t\t}\n\t\tto {\n\t\t\ttransform: translate(0px, 0px);\n\t\t}\n\t}\n\n\t[data-icon-animating] .skip-bottom {\n\t\tanimation: skipBottomAni 0.2s ease-out;\n\t\tvisibility: visible;\n\t\ttransform-origin: left center;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/dialog/CommonDialog.svelte",
    "content": "<script module lang=\"ts\">\n\timport Dialog, { type DialogData, type DialogOpen, type DialogProps } from './Dialog.svelte'\n\timport DialogFooter, { type DialogButton } from './DialogFooter.svelte'\n\n\texport interface CommonDialogProps<Open extends DialogOpen> extends DialogProps<Open> {\n\t\tbuttons?: DialogButton[] | ((data: DialogData<Open>) => DialogButton[])\n\t\tonsubmit?: (e: SubmitEvent, data: DialogData<Open>) => void\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"Open extends DialogOpen\">\n\tlet {\n\t\topen = $bindable(false) as Open,\n\t\tbuttons,\n\t\tonsubmit,\n\t\tchildren: externalChildren,\n\t\tclass: className,\n\t\t...props\n\t}: CommonDialogProps<Open> = $props()\n\n\tconst getButtonItems = (data: DialogData<Open>) => {\n\t\tif (typeof buttons === 'function') {\n\t\t\treturn buttons(data)\n\t\t}\n\n\t\treturn buttons\n\t}\n</script>\n\n<Dialog bind:open class={className} {...props}>\n\t{#snippet children({ data, close })}\n\t\t<form\n\t\t\tdata-dialog-body\n\t\t\tmethod=\"dialog\"\n\t\t\tclass=\"contents\"\n\t\t\tonsubmit={(e) => {\n\t\t\t\te.preventDefault()\n\n\t\t\t\tonsubmit?.(e, data)\n\t\t\t}}\n\t\t>\n\t\t\t{#if externalChildren}\n\t\t\t\t<div data-dialog-content class=\"mt-4 grow px-6 text-onSurfaceVariant\">\n\t\t\t\t\t{@render externalChildren({ data, close })}\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<DialogFooter buttons={getButtonItems(data)} onclose={close} />\n\t\t</form>\n\t{/snippet}\n</Dialog>\n"
  },
  {
    "path": "src/lib/components/dialog/Dialog.svelte",
    "content": "<script module lang=\"ts\">\n\timport type { AnimationConfig } from 'svelte/animate'\n\timport { type AnimationSequence, timeline } from '$lib/helpers/animations.ts'\n\timport Icon, { type IconType } from '../icon/Icon.svelte'\n\n\texport interface DialogOpenAccessor<S> {\n\t\tget: () => S | null\n\t\tclose: () => void\n\t}\n\n\texport type DialogOpen<S = unknown> = DialogOpenAccessor<S> | boolean\n\n\texport type DialogData<Open extends DialogOpen> =\n\t\tOpen extends DialogOpenAccessor<infer S> ? S : undefined\n\n\tinterface DialogBaseProps<Data> {\n\t\ttitle?: string | ((data: Data) => string)\n\t\ticon?: IconType\n\t\tclass?: ClassValue\n\t\theader?: Snippet<[{ data: Data; close: () => void }]>\n\t\tchildren?: Snippet<[{ data: Data; close: () => void }]>\n\t}\n\n\texport interface DialogProps<Open extends DialogOpen> extends DialogBaseProps<DialogData<Open>> {\n\t\topen: Open\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"Open extends DialogOpen\">\n\tlet {\n\t\topen = $bindable(false) as Open,\n\t\ttitle,\n\t\ticon,\n\t\tclass: className,\n\t\theader,\n\t\tchildren,\n\t}: DialogProps<Open> = $props()\n\n\ttype UnwrapOpen =\n\t\t| {\n\t\t\t\tisOpen: true\n\t\t\t\tdata: DialogData<Open>\n\t\t  }\n\t\t| {\n\t\t\t\tisOpen: false\n\t\t\t\tdata: null\n\t\t  }\n\n\tconst state = $derived.by(() => {\n\t\tif (typeof open === 'boolean') {\n\t\t\treturn {\n\t\t\t\tisOpen: open,\n\t\t\t\tdata: undefined,\n\t\t\t} as UnwrapOpen\n\t\t}\n\n\t\tconst data = open.get()\n\n\t\treturn {\n\t\t\tisOpen: data !== null,\n\t\t\tdata,\n\t\t} as UnwrapOpen\n\t})\n\n\tconst titleText = $derived.by(() => {\n\t\tif (typeof title === 'function') {\n\t\t\treturn state.data ? title(state.data) : ''\n\t\t}\n\n\t\treturn title\n\t})\n\n\tconst close = () => {\n\t\tif (typeof open === 'object') {\n\t\t\topen.close()\n\t\t} else {\n\t\t\topen = false as Open\n\t\t}\n\t}\n\n\tconst getParts = (dialog: HTMLDialogElement) => {\n\t\tconst dialogHeader = dialog.querySelector<HTMLElement>('[data-dialog-header]')\n\t\tconst dialogBody = dialog.querySelector<HTMLElement>('[data-dialog-content]')\n\t\tconst dialogFooter = dialog.querySelector<HTMLElement>('[data-dialog-footer]')\n\n\t\treturn { dialogHeader, dialogBody, dialogFooter }\n\t}\n\n\tconst animateBackdrop = (dialog: HTMLDialogElement, isOut = false) => {\n\t\ttry {\n\t\t\tdialog.animate(\n\t\t\t\t{\n\t\t\t\t\topacity: isOut ? [1, 0] : [0, 1],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tpseudoElement: '::backdrop',\n\t\t\t\t\tduration: 300,\n\t\t\t\t\teasing: 'linear',\n\t\t\t\t\tfill: isOut ? 'forwards' : undefined,\n\t\t\t\t},\n\t\t\t)\n\t\t} catch (err) {\n\t\t\t// Firefox does not support pseudo-element animations\n\t\t\t// https://bugzilla.mozilla.org/show_bug.cgi?id=1770591\n\t\t\tif (import.meta.env.DEV) {\n\t\t\t\tconsole.warn(err)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst animateIn = (dialog: HTMLDialogElement) => {\n\t\tconst { dialogHeader, dialogBody, dialogFooter } = getParts(dialog)\n\n\t\tconst fade = (el: HTMLElement | null): AnimationSequence | null =>\n\t\t\tel ? [el, { opacity: [0, 1] }, { duration: 300, at: '<' }] : null\n\n\t\tanimateBackdrop(dialog)\n\n\t\tconst frames: readonly AnimationSequence[] = [\n\t\t\t[\n\t\t\t\tdialog,\n\t\t\t\t{\n\t\t\t\t\ttransform: ['translateY(-20px)', 'none'],\n\t\t\t\t\tclipPath: ['inset(0% 0% 100% 0% round 24px)', 'inset(0% 0% 0% 0% round 24px)'],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 400,\n\t\t\t\t},\n\t\t\t] satisfies AnimationSequence,\n\t\t\tfade(dialogHeader),\n\t\t\tfade(dialogBody),\n\t\t\tfade(dialogFooter),\n\t\t\tdialogFooter &&\n\t\t\t\t([\n\t\t\t\t\tdialogFooter,\n\t\t\t\t\t{ transform: ['translateY(-60px)', 'none'] },\n\t\t\t\t\t{ duration: 400, at: '<' },\n\t\t\t\t] satisfies AnimationSequence),\n\t\t].filter((x) => x !== null && x !== undefined)\n\n\t\ttimeline(frames, {\n\t\t\tdefaultOptions: {\n\t\t\t\t// ease-standard\n\t\t\t\teasing: 'cubic-bezier(0.2, 0, 0, 1)',\n\t\t\t},\n\t\t})\n\t}\n\n\tconst animateOut = (dialog: HTMLDialogElement) => {\n\t\tconst { dialogHeader, dialogBody, dialogFooter } = getParts(dialog)\n\n\t\tconst fade = (el: HTMLElement | null): AnimationSequence | null =>\n\t\t\tel ? [el, { opacity: [1, 0] }, { duration: 300, at: '<' }] : null\n\n\t\tanimateBackdrop(dialog, true)\n\n\t\tconst frames: readonly AnimationSequence[] = [\n\t\t\t[\n\t\t\t\tdialog,\n\t\t\t\t{\n\t\t\t\t\ttransform: ['none', 'translateY(-20px)'],\n\t\t\t\t\tclipPath: ['inset(0% 0% 0% 0% round 24px)', 'inset(0% 0% 100% 0% round 24px)'],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 400,\n\t\t\t\t},\n\t\t\t] satisfies AnimationSequence,\n\t\t\tdialogFooter &&\n\t\t\t\t([\n\t\t\t\t\tdialogFooter,\n\t\t\t\t\t{ transform: ['none', 'translateY(-60px)'] },\n\t\t\t\t\t{ duration: 400, at: '<' },\n\t\t\t\t] satisfies AnimationSequence),\n\t\t\tfade(dialogFooter),\n\t\t\tfade(dialogBody),\n\t\t\tfade(dialogHeader),\n\t\t].filter((x) => x !== null && x !== undefined)\n\n\t\treturn timeline(frames, {\n\t\t\tdefaultOptions: {\n\t\t\t\t// ease-standard\n\t\t\t\teasing: 'cubic-bezier(0.2, 0, 0, 1)',\n\t\t\t},\n\t\t})\n\t}\n\n\tconst onOpenAction = (dialog: HTMLDialogElement) => {\n\t\tdialog.showModal()\n\t\tvoid animateIn(dialog)\n\t}\n\n\tconst outAni = (dialog: HTMLDialogElement): AnimationConfig => {\n\t\tvoid animateOut(dialog)\n\n\t\t// TODO. A hack until svelte supports non duration based animations\n\t\treturn {\n\t\t\tduration: 400,\n\t\t}\n\t}\n</script>\n\n{#if state.isOpen}\n\t<dialog\n\t\t{@attach onOpenAction}\n\t\tout:outAni\n\t\tonkeydown={(e) => {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tclose()\n\t\t\t\t// We don't want dialog to exit top level\n\t\t\t\t// and instead remain until the animation is complete\n\t\t\t\t// and then remove from the DOM\n\t\t\t\te.preventDefault()\n\t\t\t}\n\t\t}}\n\t\tonclose={() => {\n\t\t\t// There is no way to prevent dialog close event\n\t\t\tclose()\n\t\t}}\n\t\tclass={[\n\t\t\t'm-auto flex flex-col rounded-3xl bg-surfaceContainerHigh text-onSurface contain-content select-none focus:outline-none',\n\t\t\tclassName,\n\t\t]}\n\t>\n\t\t{#if header}\n\t\t\t{@render header({ data: state.data, close })}\n\t\t{:else}\n\t\t\t<div\n\t\t\t\tdata-dialog-header\n\t\t\t\tclass={['flex flex-col gap-4 px-6 pt-6', icon && 'items-center justify-center text-center']}\n\t\t\t>\n\t\t\t\t{#if icon}\n\t\t\t\t\t<Icon type={icon} class=\"text-secondary\" />\n\t\t\t\t{/if}\n\n\t\t\t\t{#if titleText}\n\t\t\t\t\t<div class=\"text-headline-sm\">{titleText}</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<div class=\"flex shrink flex-col overflow-hidden\">\n\t\t\t{@render children?.({\n\t\t\t\tdata: state.data,\n\t\t\t\tclose,\n\t\t\t})}\n\t\t</div>\n\t</dialog>\n{/if}\n\n<style lang=\"postcss\">\n\t@reference '../../../app.css';\n\n\tdialog {\n\t\t/*\n\t\t\tWe want to allow user of dialog to specify their preferred height\n\t\t\tbut keep it inside window bounds\n\t\t*/\n\t\tmax-width: initial !important;\n\t\tmax-height: min(100% - --spacing(6) * 2, var(--dialog-height, 100%), --spacing(150)) !important;\n\t\twidth: clamp(\n\t\t\t--spacing(70),\n\t\t\tvar(--dialog-width, --spacing(100)),\n\t\t\t100% - --spacing(8)\n\t\t) !important;\n\t\theight: max-content !important;\n\t\toverscroll-behavior: contain;\n\t}\n\n\tdialog::backdrop {\n\t\tbackground: rgba(0, 0, 0, 0.22);\n\t\tbackdrop-filter: blur(4px);\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/dialog/DialogFooter.svelte",
    "content": "<script module lang=\"ts\">\n\timport Button, { type ButtonKind } from '../Button.svelte'\n\n\texport interface DialogButton<S = void> {\n\t\ttitle: string\n\t\talign?: 'left'\n\t\tkind?: ButtonKind\n\t\ttype?: 'submit' | 'button' | 'reset' | 'close'\n\t\taction?: (data: S) => void | Promise<void>\n\t}\n\n\texport interface DialogButtonProps<S = void> {\n\t\tbuttons?: DialogButton<S>[]\n\t\tstate?: S\n\t\tonclose: () => void\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"S\">\n\tlet { buttons = [], onclose, state }: DialogButtonProps<S> = $props()\n</script>\n\n{#if buttons?.length}\n\t<div data-dialog-footer class=\"flex justify-end gap-2 p-6\">\n\t\t{#each buttons as button}\n\t\t\t<Button\n\t\t\t\tkind={button.kind ?? 'flat'}\n\t\t\t\tclass={['min-w-15', button.align === 'left' && 'mr-auto']}\n\t\t\t\ttype={button.type === 'close' ? 'button' : button.type}\n\t\t\t\tonclick={async () => {\n\t\t\t\t\tawait button.action?.(state as S)\n\t\t\t\t\tif (!button.type || button.type === 'close') {\n\t\t\t\t\t\tonclose()\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{button.title}\n\t\t\t</Button>\n\t\t{/each}\n\t</div>\n{/if}\n"
  },
  {
    "path": "src/lib/components/global-dialogs/EqualizerDialog.svelte",
    "content": "<script lang=\"ts\" module>\n\timport Button from '$lib/components/Button.svelte'\n\timport Dialog, { type DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\n\timport Separator from '$lib/components/Separator.svelte'\n\timport Slider from '$lib/components/Slider.svelte'\n\timport Switch from '$lib/components/Switch.svelte'\n\timport {\n\t\ttype BuiltinEqPresetKey,\n\t\tEQ_BANDS,\n\t\tEQ_MAX_GAIN,\n\t\tEQ_MIN_GAIN,\n\t} from '$lib/stores/player/equalizer.svelte.ts'\n\n\texport interface EqualizerDialogProps {\n\t\topen: DialogOpenAccessor<boolean>\n\t}\n</script>\n\n<script lang=\"ts\">\n\tlet { open }: EqualizerDialogProps = $props()\n\n\tconst player = usePlayer()\n\tconst eq = $derived(player.equalizer)\n\n\tconst presets: [BuiltinEqPresetKey, string][] = [\n\t\t['flat', m.equalizerPresetFlat()],\n\t\t['bassBoost', m.equalizerPresetBassBoost()],\n\t\t['trebleBoost', m.equalizerPresetTrebleBoost()],\n\t\t['rock', m.equalizerPresetRock()],\n\t\t['pop', m.equalizerPresetPop()],\n\t\t['jazz', m.equalizerPresetJazz()],\n\t\t['classical', m.equalizerPresetClassical()],\n\t\t['electronic', m.equalizerPresetElectronic()],\n\t\t['acoustic', m.equalizerPresetAcoustic()],\n\t]\n</script>\n\n<Dialog {open} class=\"[--dialog-width:--spacing(160)]\">\n\t{#snippet header()}\n\t\t<header data-dialog-header class=\"flex items-center justify-between px-6 py-6\">\n\t\t\t<div class=\"text-headline-sm\">{m.equalizerTitle()}</div>\n\n\t\t\t<Switch bind:checked={eq.enabled} />\n\t\t</header>\n\t{/snippet}\n\n\t{#snippet children({ close })}\n\t\t<div data-dialog-content class=\"flex flex-col\">\n\t\t\t<Separator />\n\n\t\t\t<div class=\"mb-2 flex gap-2 overflow-x-auto px-6 pt-4 pb-4\">\n\t\t\t\t{#each presets as [preset, label]}\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t'interactable shrink-0 rounded-full px-3 py-1 text-label-lg transition-colors',\n\t\t\t\t\t\t\teq.selectedPreset === preset\n\t\t\t\t\t\t\t\t? 'bg-primary text-onPrimary'\n\t\t\t\t\t\t\t\t: 'bg-secondaryContainer text-onSecondaryContainer',\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tonclick={() => eq.applyPreset(preset)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</button>\n\t\t\t\t{/each}\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclass=\"sliders-columns grid gap-3 overflow-x-auto overflow-y-hidden overscroll-none px-4 pb-3\"\n\t\t\t>\n\t\t\t\t{#each EQ_BANDS as band, i}\n\t\t\t\t\t{@const gain = eq.bands[i] ?? 0}\n\t\t\t\t\t<div class=\"flex flex-col items-center gap-2\">\n\t\t\t\t\t\t<span class=\"text-label-sm tabular-nums\">\n\t\t\t\t\t\t\t{gain > 0 ? '+' : ''}{Math.round(gain)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div class=\"h-40\">\n\t\t\t\t\t\t\t<Slider\n\t\t\t\t\t\t\t\tvertical\n\t\t\t\t\t\t\t\tmin={EQ_MIN_GAIN}\n\t\t\t\t\t\t\t\tmax={EQ_MAX_GAIN}\n\t\t\t\t\t\t\t\tstep={0.5}\n\t\t\t\t\t\t\t\tbind:value={() => gain, (v) => eq.setBand(i, v)}\n\t\t\t\t\t\t\t\tdisabled={!eq.enabled}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<span class=\"text-label-sm text-onSurfaceVariant tabular-nums\">{band.label}</span>\n\t\t\t\t\t</div>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div data-dialog-footer class=\"flex items-center justify-between px-6 pt-3 pb-6\">\n\t\t\t<Button kind=\"outlined\" onclick={() => eq.reset()}>{m.equalizerReset()}</Button>\n\t\t\t<Button kind=\"flat\" onclick={close}>{m.equalizerClose()}</Button>\n\t\t</div>\n\t{/snippet}\n</Dialog>\n\n<style lang=\"postcss\">\n\t@reference '../../../app.css';\n\t.sliders-columns {\n\t\tgrid-template-columns: repeat(10, minmax(--spacing(12), 1fr));\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/global-dialogs/RemoveFromLibraryDialog.svelte",
    "content": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport { createUIAction } from '$lib/helpers/ui-action'\n\timport { truncate } from '$lib/helpers/utils/text.ts'\n\timport { dbRemovePlaylist } from '$lib/library/playlists-actions.ts'\n\timport { dbRemoveAlbum, dbRemoveArtist, dbRemoveTracks } from '$lib/library/remove.ts'\n\timport type { LibraryStoreName } from '$lib/library/types'\n\timport type { DialogOpenAccessor } from '../dialog/Dialog.svelte'\n\n\ttype RemoveLibraryItemOptions =\n\t\t| {\n\t\t\t\ttype: 'single'\n\t\t\t\tid: number\n\t\t\t\tname: string\n\t\t\t\tstoreName: LibraryStoreName\n\t\t  }\n\t\t| {\n\t\t\t\ttype: 'multiple'\n\t\t\t\tids: readonly number[]\n\t\t\t\tstoreName: 'tracks'\n\t\t  }\n\n\texport interface RemoveFromLibraryDialogProps {\n\t\topen: DialogOpenAccessor<RemoveLibraryItemOptions>\n\t}\n</script>\n\n<script lang=\"ts\">\n\tlet { open }: RemoveFromLibraryDialogProps = $props()\n\n\tconst removeSingle = createUIAction(\n\t\tm.libraryItemRemovedFromLibrary(),\n\t\t(store: LibraryStoreName, id: number) => {\n\t\t\tswitch (store) {\n\t\t\t\tcase 'playlists':\n\t\t\t\t\treturn dbRemovePlaylist(id)\n\t\t\t\tcase 'tracks':\n\t\t\t\t\treturn dbRemoveTracks([id])\n\t\t\t\tcase 'albums':\n\t\t\t\t\treturn dbRemoveAlbum(id)\n\t\t\t\tcase 'artists':\n\t\t\t\t\treturn dbRemoveArtist(id)\n\t\t\t}\n\t\t},\n\t)\n\n\tconst removeMultiple = createUIAction(\n\t\tm.libraryItemsRemovedFromLibrary(),\n\t\t(store: LibraryStoreName, ids: readonly number[]) => {\n\t\t\tinvariant(store === 'tracks', 'Only tracks can be removed in bulk')\n\n\t\t\treturn dbRemoveTracks(ids)\n\t\t},\n\t)\n</script>\n\n<CommonDialog\n\t{open}\n\ttitle={(data) => {\n\t\tif (data.type === 'multiple') {\n\t\t\treturn m.libraryConfirmRemoveMultipleTitle({\n\t\t\t\tcount: data.ids.length,\n\t\t\t})\n\t\t}\n\n\t\treturn m.libraryConfirmRemoveTitle({\n\t\t\tname: truncate(data.name ?? '', 10),\n\t\t})\n\t}}\n\tbuttons={[\n\t\t{\n\t\t\ttitle: m.libraryCancel(),\n\t\t},\n\t\t{\n\t\t\ttitle: m.libraryRemove(),\n\t\t\ttype: 'submit',\n\t\t},\n\t]}\n\tonsubmit={(_, data) => {\n\t\topen.close()\n\n\t\tif (data.type === 'multiple') {\n\t\t\tvoid removeMultiple(data.storeName, data.ids)\n\t\t\treturn\n\t\t}\n\n\t\tvoid removeSingle(data.storeName, data.id)\n\t}}\n/>\n"
  },
  {
    "path": "src/lib/components/global-dialogs/dialogs.ts",
    "content": "import type { Component } from 'svelte'\nimport type { DialogOpenAccessor } from '../dialog/Dialog.svelte'\nimport EqualizerDialog from './EqualizerDialog.svelte'\nimport AddToPlaylistDialog from './playlists/AddToPlaylistDialog.svelte'\nimport EditPlaylistDialog from './playlists/EditPlaylistDialog.svelte'\nimport NewPlaylistDialog from './playlists/NewPlaylistDialog.svelte'\nimport RemoveFromLibraryDialog from './RemoveFromLibraryDialog.svelte'\n\n// biome-ignore lint/suspicious/noExplicitAny: needed for inference\ntype ComponentWithOpenProp = Component<{ open: DialogOpenAccessor<any> }>\n\nexport const APP_DIALOGS_COMPONENTS_MAP = {\n\tequalizer: EqualizerDialog,\n\tremoveFromLibrary: RemoveFromLibraryDialog,\n\taddToPlaylist: AddToPlaylistDialog,\n\tnewPlaylist: NewPlaylistDialog,\n\teditPlaylist: EditPlaylistDialog,\n} satisfies Record<string, ComponentWithOpenProp>\n\nexport type AppDialogKey = keyof typeof APP_DIALOGS_COMPONENTS_MAP\n\nexport const APP_DIALOGS_KEYS = Object.keys(APP_DIALOGS_COMPONENTS_MAP) as AppDialogKey[]\n"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/AddToPlaylistDialog.svelte",
    "content": "<script lang=\"ts\" module>\n\timport Dialog, { type DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\n\timport DialogFooter from '$lib/components/dialog/DialogFooter.svelte'\n\timport Separator from '$lib/components/Separator.svelte'\n\timport AddToPlaylistDialogContent from './AddToPlaylistDialogContent.svelte'\n\n\texport interface AddToPlaylistDialogProps {\n\t\topen: DialogOpenAccessor<readonly number[]>\n\t}\n</script>\n\n<script lang=\"ts\">\n\tlet { open }: AddToPlaylistDialogProps = $props()\n\n\tconst dialogs = useDialogsStore()\n\n\tconst dialogTitle = (tracks: readonly number[]) => {\n\t\tconst count = tracks.length ?? 0\n\t\tconst countLabel = count > 1 ? ` (${count})` : ''\n\n\t\treturn `${m.libraryAddToPlaylist()}${countLabel}`\n\t}\n</script>\n\n<Dialog {open} title={dialogTitle}>\n\t{#snippet children({ data: trackIds, close })}\n\t\t<svelte:boundary\n\t\t\tonerror={(e) => {\n\t\t\t\tsnackbar.unexpectedError(e)\n\t\t\t\tqueueMicrotask(() => {\n\t\t\t\t\tclose()\n\t\t\t\t})\n\t\t\t}}\n\t\t>\n\t\t\t<AddToPlaylistDialogContent {trackIds}>\n\t\t\t\t{#snippet children({ save })}\n\t\t\t\t\t<Separator />\n\t\t\t\t\t<DialogFooter\n\t\t\t\t\t\tbuttons={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttitle: m.libraryCreateNewPlaylist(),\n\t\t\t\t\t\t\t\talign: 'left',\n\t\t\t\t\t\t\t\ttype: 'button',\n\t\t\t\t\t\t\t\taction: () => {\n\t\t\t\t\t\t\t\t\tdialogs.openDialog('newPlaylist')\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttitle: m.libraryCancel(),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttitle: m.librarySave(),\n\t\t\t\t\t\t\t\taction: save,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tonclose={close}\n\t\t\t\t\t/>\n\t\t\t\t{/snippet}\n\t\t\t</AddToPlaylistDialogContent>\n\t\t</svelte:boundary>\n\t{/snippet}\n</Dialog>\n"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/AddToPlaylistDialogContent.svelte",
    "content": "<script lang=\"ts\">\n\timport { SvelteMap } from 'svelte/reactivity'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport PlaylistListContainer from '$lib/components/playlists/PlaylistListContainer.svelte'\n\timport ScrollContainer from '$lib/components/ScrollContainer.svelte'\n\timport Separator from '$lib/components/Separator.svelte'\n\timport TextField from '$lib/components/TextField.svelte'\n\timport { getDatabase } from '$lib/db/database.ts'\n\timport { createInlineQuery } from '$lib/db/query/inline-query.svelte'\n\timport { createQuery } from '$lib/db/query/query.ts'\n\timport { getLibraryItemIds } from '$lib/library/get/ids'\n\timport { dbBatchModifyPlaylistsSelection } from '$lib/library/playlists-actions'\n\n\tinterface Props {\n\t\ttrackIds: readonly number[]\n\t\tchildren: Snippet<[{ save: () => Promise<void> }]>\n\t}\n\n\tconst { trackIds, children }: Props = $props()\n\n\tlet searchTerm = $state('')\n\n\tconst getPlaylists = createInlineQuery({\n\t\tkey: () => [searchTerm],\n\t\tfetcher: () =>\n\t\t\tgetLibraryItemIds('playlists', {\n\t\t\t\tsort: 'createdAt',\n\t\t\t\torder: 'desc',\n\t\t\t\tsearchTerm: searchTerm.trim().toLowerCase(),\n\t\t\t\tsearchFn: (p, term) => p.name.trim().toLowerCase().includes(term),\n\t\t\t}),\n\t\tonDatabaseChange: (changes) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === 'playlists') {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn false\n\t\t},\n\t})\n\n\tconst playlistsIds = $derived(await getPlaylists())\n\n\tconst initialTrackPlaylists = createQuery({\n\t\t// We only care about initial values\n\t\tkey: [],\n\t\tfetcher: async () => {\n\t\t\tconst firstTrackId = trackIds.at(0)\n\t\t\t// In case there are multiple track ids, we treat as if there are no items added in the playlist\n\t\t\tif (trackIds.length > 1 || !firstTrackId) {\n\t\t\t\treturn null\n\t\t\t}\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst items = await db.getAllFromIndex('playlistEntries', 'trackId', firstTrackId)\n\n\t\t\treturn items\n\t\t},\n\t})\n\n\ttype SelectionStatus = 'added-already' | 'add' | 'remove'\n\tconst selection = new SvelteMap</* playlist id */ number, SelectionStatus>()\n\n\t$effect(() => {\n\t\tif (initialTrackPlaylists.status === 'loaded') {\n\t\t\tuntrack(() => {\n\t\t\t\tfor (const playlistEntry of initialTrackPlaylists.value ?? []) {\n\t\t\t\t\tselection.set(playlistEntry.playlistId, 'added-already')\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tconst isTrackInPlaylist = (playlistId: number) => {\n\t\tconst selectionState = selection.get(playlistId)\n\n\t\treturn selectionState === 'added-already' || selectionState === 'add'\n\t}\n\n\tconst toggleSelection = (playlistId: number) => {\n\t\tconst selectionState = selection.get(playlistId)\n\n\t\tif (selectionState === 'added-already') {\n\t\t\tselection.set(playlistId, 'remove')\n\t\t} else if (selectionState === 'add') {\n\t\t\tselection.delete(playlistId)\n\t\t} else if (selectionState === 'remove') {\n\t\t\tselection.set(playlistId, 'added-already')\n\t\t} else {\n\t\t\tselection.set(playlistId, 'add')\n\t\t}\n\t}\n\n\tconst dbSave = () => {\n\t\tconst playlistsIdsRemoveFrom: number[] = []\n\t\tconst playlistsIdsAddTo: number[] = []\n\t\tfor (const [playlistId, status] of selection) {\n\t\t\tif (status === 'remove') {\n\t\t\t\tplaylistsIdsRemoveFrom.push(playlistId)\n\t\t\t} else if (status === 'add') {\n\t\t\t\tplaylistsIdsAddTo.push(playlistId)\n\t\t\t}\n\t\t}\n\n\t\treturn dbBatchModifyPlaylistsSelection({\n\t\t\ttrackIds,\n\t\t\tplaylistsIdsAddTo,\n\t\t\tplaylistsIdsRemoveFrom,\n\t\t})\n\t}\n\n\tconst save = async () => {\n\t\ttry {\n\t\t\tconst changed = await dbSave()\n\t\t\tif (changed) {\n\t\t\t\tsnackbar({ id: 'playlists-updated', message: m.libraryPlaylistsUpdated() })\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tsnackbar.unexpectedError(error)\n\t\t}\n\t}\n</script>\n\n<div class=\"p-4\">\n\t<TextField bind:value={searchTerm} name=\"search\" placeholder={m.librarySearch()} />\n</div>\n\n<Separator />\n<ScrollContainer class=\"max-h-100 grow overflow-auto px-2 py-4\">\n\t<PlaylistListContainer\n\t\titems={playlistsIds}\n\t\tonItemClick={(item) => {\n\t\t\ttoggleSelection(item.playlist.id)\n\t\t}}\n\t>\n\t\t{#snippet icon(playlist)}\n\t\t\t{@const isInPlaylist = isTrackInPlaylist(playlist.id)}\n\t\t\t<div\n\t\t\t\tclass={[\n\t\t\t\t\t'flex size-6 items-center justify-center rounded-full border-2',\n\t\t\t\t\tisInPlaylist ? 'border-primary bg-primary text-onPrimary' : 'border-neutral',\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t{#if isInPlaylist}\n\t\t\t\t\t<Icon type=\"check\" />\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/snippet}\n\t</PlaylistListContainer>\n</ScrollContainer>\n\n{@render children({ save })}\n"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/EditPlaylistDialog.svelte",
    "content": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport type { DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\n\timport TextField from '$lib/components/TextField.svelte'\n\timport { type UpdatePlaylistOptions, updatePlaylist } from '$lib/library/playlists-actions'\n\n\texport interface EditPlaylistDialogProps {\n\t\topen: DialogOpenAccessor<UpdatePlaylistOptions>\n\t}\n</script>\n\n<script lang=\"ts\">\n\tlet { open }: EditPlaylistDialogProps = $props()\n\n\tconst submitHandler = async (event: SubmitEvent, data: UpdatePlaylistOptions) => {\n\t\tinvariant(data !== null, 'Playlist to edit is not set')\n\n\t\tconst formData = new FormData(event.target as HTMLFormElement)\n\t\tconst name = formData.get('playlistName') as string\n\t\tconst description = formData.get('description') as string\n\n\t\tconst success = await updatePlaylist({\n\t\t\tid: data.id,\n\t\t\tname,\n\t\t\tdescription,\n\t\t})\n\n\t\tif (success) {\n\t\t\topen.close()\n\t\t}\n\t}\n</script>\n\n<CommonDialog\n\t{open}\n\ticon=\"addPlaylist\"\n\ttitle={m.libraryEditPlaylistName()}\n\tbuttons={[\n\t\t{\n\t\t\ttitle: m.libraryCancel(),\n\t\t},\n\t\t{\n\t\t\ttitle: m.librarySave(),\n\t\t\ttype: 'submit',\n\t\t},\n\t]}\n\tonsubmit={submitHandler}\n>\n\t{#snippet children({ data })}\n\t\t<TextField\n\t\t\tvalue={data.name}\n\t\t\tname=\"playlistName\"\n\t\t\tplaceholder={m.libraryPlaylistName()}\n\t\t\trequired\n\t\t\tminLength={4}\n\t\t\tmaxLength={40}\n\t\t/>\n\n\t\t<TextField\n\t\t\tvalue={data.description}\n\t\t\tname=\"description\"\n\t\t\tplaceholder={m.description()}\n\t\t\tmaxLength={200}\n\t\t\tclass=\"mt-6\"\n\t\t/>\n\t{/snippet}\n</CommonDialog>\n"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/NewPlaylistDialog.svelte",
    "content": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport type { DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\n\timport TextField from '$lib/components/TextField.svelte'\n\timport { createPlaylist } from '$lib/library/playlists-actions.ts'\n\n\texport interface NewPlaylistDialogProps {\n\t\topen: DialogOpenAccessor<boolean>\n\t}\n</script>\n\n<script lang=\"ts\">\n\tlet { open }: NewPlaylistDialogProps = $props()\n\n\tconst onSubmitHandler = async (event: SubmitEvent) => {\n\t\tconst formData = new FormData(event.target as HTMLFormElement)\n\t\tconst name = formData.get('playlistName') as string\n\t\tconst description = formData.get('description') as string\n\n\t\tawait createPlaylist(name, description)\n\n\t\topen.close()\n\t}\n</script>\n\n<CommonDialog\n\t{open}\n\ticon=\"addPlaylist\"\n\ttitle={m.libraryCreateNewPlaylist()}\n\tbuttons={[\n\t\t{\n\t\t\ttitle: m.libraryCancel(),\n\t\t},\n\t\t{\n\t\t\ttitle: m.libraryCreate(),\n\t\t\ttype: 'submit',\n\t\t},\n\t]}\n\tonsubmit={onSubmitHandler}\n>\n\t<TextField\n\t\tname=\"playlistName\"\n\t\tplaceholder={m.libraryPlaylistName()}\n\t\trequired\n\t\tminLength={4}\n\t\tmaxLength={40}\n\t/>\n\n\t<TextField name=\"description\" placeholder={m.description()} maxLength={200} class=\"mt-6\" />\n</CommonDialog>\n"
  },
  {
    "path": "src/lib/components/icon/Icon.svelte",
    "content": "<script lang=\"ts\" module>\n\timport type { IconType } from './icon-paths.server.ts'\n\n\texport type { IconType } from './icon-paths.server.ts'\n\n\texport interface IconProps {\n\t\ttype: IconType\n\t\tclass?: ClassValue\n\t}\n</script>\n\n<script lang=\"ts\">\n\tconst { type, class: className }: IconProps = $props()\n</script>\n\n<svg\n\trole=\"presentation\"\n\twidth=\"24\"\n\theight=\"24\"\n\tviewBox=\"0 0 24 24\"\n\tclass={['pointer-events-none shrink-0 fill-current', className]}\n>\n\t<use href={`#system-icon-${type}`} />\n</svg>\n"
  },
  {
    "path": "src/lib/components/icon/icon-paths.server.ts",
    "content": "// Icons taken from https://pictogrammers.com/library/mdi/\n// and then minified using https://jakearchibald.github.io/svgomg/\n\nexport const ICON_PATHS = {\n\taddPlaylist: 'M2 16h8v-2H2m16 0v-4h-2v4h-4v2h4v4h2v-4h4v-2m-8-8H2v2h12m0 2H2v2h12v-2z',\n\talbum: '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',\n\talertCircle:\n\t\t'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',\n\tbackArrow: 'M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z',\n\tcached: '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',\n\tcellphone:\n\t\t'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',\n\tcheck: 'M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59 21 7Z',\n\tchevronRight: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z',\n\tchevronUp: 'M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z',\n\tclose: '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',\n\tdelete: 'M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 002 2h8a2 2 0 002-2V7H6v12z',\n\tdiscord:\n\t\t'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',\n\tdragHorizontal: 'M21 11H3V9H21V11M21 13H3V15H21V13Z',\n\tequalizer: 'M10,20H14V4H10V20M4,20H8V12H4V20M16,9V20H20V9H16Z',\n\teyedropper:\n\t\t'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',\n\tfavorite:\n\t\t'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',\n\tfavoriteOutline:\n\t\t'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',\n\tflash: 'M7 2v11h3v9l7-12h-4l3-8H7Z',\n\tfolder: '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',\n\tfolderHidden:\n\t\t'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',\n\tgithub: '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',\n\theadphones:\n\t\t'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',\n\thome: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8h5Z',\n\tinformation:\n\t\t'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',\n\tlock: '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',\n\tlockCheck:\n\t\t'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',\n\tmagnify:\n\t\t'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',\n\tmenuDown: 'M7,10L12,15L17,10H7Z',\n\tmoreVertical:\n\t\t'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',\n\tmusicNote:\n\t\t'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',\n\topenInNew:\n\t\t'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',\n\tpalette:\n\t\t'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',\n\tperson: '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',\n\tplaylist:\n\t\t'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',\n\tplaylistMusic:\n\t\t'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',\n\tplus: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z',\n\tsearch: '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',\n\tshuffle:\n\t\t'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',\n\tsidePanel:\n\t\t'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',\n\tsort: 'M3 13h12v-2H3m0-5v2h18V6M3 18h6v-2H3v2z',\n\tsortAscending:\n\t\t'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',\n\ttrashOutline:\n\t\t'M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12M8 9h8v10H8V9m7.5-5-1-1h-5l-1 1H5v2h14V4h-3.5Z',\n\ttrayFull:\n\t\t'M18 5H6V7H18M6 9H18V11H6M2 12H4V17H20V12H22V17A2 2 0 0 1 20 19H4A2 2 0 0 1 2 17M18 13H6V15H18Z',\n\ttrayRemove:\n\t\t'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',\n\tvolumeHigh:\n\t\t'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',\n\tvolumeMid:\n\t\t'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',\n} as const\n\n/** @public */\nexport type IconType = keyof typeof ICON_PATHS\n"
  },
  {
    "path": "src/lib/components/library-grid/LibraryGridItem.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { goto } from '$app/navigation'\n\timport { resolve } from '$app/paths'\n\timport { page } from '$app/state'\n\timport type { RouteId } from '$app/types'\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport type { QueryResult } from '$lib/db/query/query.ts'\n\timport { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte.ts'\n\timport { dbGetAlbumTracksIdsByName, dbGetArtistTracksIdsByName } from '$lib/library/get/ids'\n\timport type { AlbumData, ArtistData } from '$lib/library/get/value'\n\timport { createAlbumQuery, createArtistQuery } from '$lib/library/get/value-queries'\n\timport Artwork from '../Artwork.svelte'\n\n\texport type LibraryGridItemType = 'albums' | 'artists'\n\n\texport type LibraryGridItemValue<Type extends LibraryGridItemType> = {\n\t\talbums: AlbumData\n\t\tartists: ArtistData\n\t}[Type]\n\n\texport interface LibraryItemGridItemProps<Type extends LibraryGridItemType> {\n\t\titemId: number\n\t\ttype: Type\n\t\tclass: ClassValue\n\t\tstyle: string\n\t\tchildren: Snippet<[LibraryGridItemValue<Type>]>\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"Type extends LibraryGridItemType\">\n\tconst {\n\t\ttype,\n\t\titemId,\n\t\tclass: className,\n\t\tchildren,\n\t\t...props\n\t}: LibraryItemGridItemProps<Type> = $props()\n\n\tconst menu = useMenu()\n\tconst dialogs = useDialogsStore()\n\tconst player = usePlayer()\n\n\ttype Value = LibraryGridItemValue<Type>\n\n\tconst query =\n\t\t// prettier-ignore\n\t\t(\n\t\t\t// svelte-ignore state_referenced_locally only initialized once\n\t\t\ttype === 'albums' ? createAlbumQuery(() => itemId) : createArtistQuery(() => itemId)\n\t\t) as QueryResult<Value>\n\tconst { value: item } = $derived(query)\n\n\tconst artworkSrc = createManagedArtwork(() => {\n\t\tif (type === 'albums') {\n\t\t\treturn item ? (item as AlbumData).image : undefined\n\t\t}\n\n\t\treturn undefined\n\t})\n\n\tconst linkProps = $derived.by(() => {\n\t\tconst item = query.value\n\t\tif (!item) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst detailsViewId: RouteId = '/(app)/library/[[slug=libraryEntities]]/[uuid]'\n\t\tconst shouldReplace = page.route.id === detailsViewId\n\n\t\tconst resolvedHref = resolve('/(app)/library/[[slug=libraryEntities]]/[uuid]', {\n\t\t\tslug: type,\n\t\t\tuuid: item.uuid,\n\t\t})\n\n\t\treturn {\n\t\t\thref: resolvedHref,\n\t\t\tshouldReplace,\n\t\t}\n\t})\n\n\tconst dbGetAlbumOrArtistTrackIdsByName = (name: string) => {\n\t\tif (type === 'albums') {\n\t\t\treturn dbGetAlbumTracksIdsByName(name)\n\t\t}\n\n\t\treturn dbGetArtistTracksIdsByName(name)\n\t}\n\n\tconst menuItems = () => {\n\t\tif (!(item && linkProps)) {\n\t\t\treturn []\n\t\t}\n\n\t\treturn [\n\t\t\t{\n\t\t\t\tlabel: m.libraryViewDetails(),\n\t\t\t\taction: () => {\n\t\t\t\t\tgoto(linkProps.href, { replaceState: linkProps.shouldReplace })\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.playerAddToQueue(),\n\t\t\t\taction: async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst tracksIds = await dbGetAlbumOrArtistTrackIdsByName(item.name)\n\n\t\t\t\t\t\tplayer.addToQueue(tracksIds)\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tsnackbar.unexpectedError(error)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.libraryAddToPlaylist(),\n\t\t\t\taction: async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst tracksIds = await dbGetAlbumOrArtistTrackIdsByName(item.name)\n\n\t\t\t\t\t\tdialogs.openDialog('addToPlaylist', tracksIds)\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tsnackbar.unexpectedError(error)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.libraryRemoveFromLibrary(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('removeFromLibrary', {\n\t\t\t\t\t\ttype: 'single',\n\t\t\t\t\t\tid: item.id,\n\t\t\t\t\t\tname: item.name,\n\t\t\t\t\t\tstoreName: type,\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\t}\n</script>\n\n<a\n\t{@attach ripple()}\n\t{...props}\n\trole=\"listitem\"\n\tclass={[className, 'interactable flex flex-col rounded-lg bg-surfaceContainerHigh']}\n\thref={linkProps?.href}\n\tdata-sveltekit-replacestate={linkProps?.shouldReplace}\n\toncontextmenu={(e) => {\n\t\te.preventDefault()\n\t\tmenu.showFromEvent(e, menuItems(), {\n\t\t\tanchor: false,\n\t\t\tposition: { top: e.y, left: e.x },\n\t\t})\n\t}}\n>\n\t<Artwork\n\t\tsrc={artworkSrc()}\n\t\tfallbackIcon={type === 'albums' ? 'album' : 'person'}\n\t\tclass=\"w-full rounded-[inherit]\"\n\t/>\n\n\t<div\n\t\tclass=\"flex h-18 w-full flex-col justify-center overflow-hidden px-2 text-center text-onSurfaceVariant\"\n\t>\n\t\t{#if query.loading}\n\t\t\t<div class=\"mb-2 h-2 rounded-xs bg-onSurface/10\"></div>\n\t\t\t<div class=\"h-1 w-1/8 rounded-xs bg-onSurface/20\"></div>\n\t\t{:else if query.error}\n\t\t\t{m.errorUnexpected()}\n\t\t{:else if item}\n\t\t\t{@render children(item)}\n\t\t{/if}\n\t</div>\n</a>\n"
  },
  {
    "path": "src/lib/components/library-grid/LibraryGridListContainer.svelte",
    "content": "<script lang=\"ts\" module>\n\timport VirtualContainer from '$lib/components/VirtualContainer.svelte'\n\timport { safeInteger } from '$lib/helpers/utils/integers.ts'\n\timport LibraryGridItem, {\n\t\ttype LibraryGridItemType,\n\t\ttype LibraryItemGridItemProps,\n\t} from './LibraryGridItem.svelte'\n\n\tinterface Props<Type extends LibraryGridItemType> {\n\t\ttype: Type\n\t\titems: readonly number[]\n\t\titem: LibraryItemGridItemProps<Type>['children']\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"Type extends LibraryGridItemType\">\n\tconst { items, type, item: itemSnippet }: Props<Type> = $props()\n\n\tlet containerWidth = $state(0)\n\n\tconst gap = 8\n\n\tconst sizes = $derived.by(() => {\n\t\tconst minWidth = containerWidth > 600 ? 180 : 140\n\n\t\tconst columns = safeInteger(Math.floor(containerWidth / minWidth), 1)\n\t\tconst width = safeInteger(Math.floor((containerWidth - gap * (columns - 1)) / columns))\n\n\t\tconst height = width + 72\n\n\t\treturn {\n\t\t\twidth,\n\t\t\theight: height + gap,\n\t\t\tcolumns,\n\t\t\theightWithoutGap: height,\n\t\t}\n\t})\n</script>\n\n<VirtualContainer\n\tbind:offsetWidth={containerWidth}\n\t{gap}\n\tcount={items.length}\n\tsize={sizes.height}\n\tlanes={sizes.columns}\n\tkey={(index) => `${items[index]}-${index}`}\n>\n\t{#snippet children(item)}\n\t\t<LibraryGridItem\n\t\t\titemId={items[item.index] as number}\n\t\t\t{type}\n\t\t\tstyle=\"\n\t\t\t\tleft: {item.lane * sizes.width + item.lane * gap}px;\n\t\t\t\twidth: {sizes.width}px;\n\t\t\t\theight: {item.size - gap}px;\n\t\t\t\ttransform: translateY({item.start}px);\n\t\t\t\"\n\t\t\tclass=\"virtual-item top-0\"\n\t\t>\n\t\t\t{#snippet children(itemValue)}\n\t\t\t\t{@render itemSnippet(itemValue)}\n\t\t\t{/snippet}\n\t\t</LibraryGridItem>\n\t{/snippet}\n</VirtualContainer>\n"
  },
  {
    "path": "src/lib/components/menu/Menu.svelte",
    "content": "<script lang=\"ts\">\n\timport { untrack } from 'svelte'\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport type { MenuItem } from './types.ts'\n\n\ttype Handler = (el: HTMLDialogElement) => void\n\n\tinterface Props {\n\t\titems: readonly MenuItem[]\n\t\tonopen: Handler\n\t\tonclose: Handler\n\t}\n\n\tconst { items, onopen, onclose }: Props = $props()\n\n\tlet menuEl = $state<HTMLDialogElement>()\n\n\tconst passHandler = (handler: Handler) => {\n\t\tinvariant(menuEl, 'menu container is undefined')\n\n\t\thandler(menuEl)\n\t}\n\n\tconst close = () => passHandler(onclose)\n\n\t$effect(() => {\n\t\tuntrack(() => {\n\t\t\tpassHandler(onopen)\n\t\t\tmenuEl?.querySelector('button')?.focus()\n\t\t})\n\t})\n\n\tconst keydownHandler = (e: KeyboardEvent) => {\n\t\tif (e.key === 'Escape') {\n\t\t\tclose()\n\t\t\t// We don't want dialog to exit top level\n\t\t\t// and instead remain until the animation is complete\n\t\t\t// and then remove from the DOM\n\t\t\te.preventDefault()\n\n\t\t\treturn\n\t\t}\n\n\t\tif (e.key === 'ArrowDown') {\n\t\t\te.preventDefault()\n\t\t\tconst next = menuEl?.querySelector('button:focus')\n\t\t\t\t?.nextElementSibling as HTMLButtonElement | null\n\n\t\t\tnext?.focus()\n\t\t}\n\n\t\tif (e.key === 'ArrowUp') {\n\t\t\te.preventDefault()\n\t\t\tconst prev = menuEl?.querySelector('button:focus')\n\t\t\t\t?.previousElementSibling as HTMLButtonElement | null\n\n\t\t\tprev?.focus()\n\t\t}\n\n\t\tif (e.key === 'Tab') {\n\t\t\te.preventDefault()\n\t\t\tclose()\n\t\t}\n\t}\n\n\tconst pointerDownHandler = (e: PointerEvent) => {\n\t\tif (e.target === menuEl) {\n\t\t\tclose()\n\t\t}\n\t}\n</script>\n\n<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->\n<dialog\n\tbind:this={menuEl}\n\trole=\"application\"\n\ttabindex=\"-1\"\n\tclass=\"pointer-events-auto fixed overscroll-contain rounded-sm bg-surfaceContainerHigh shadow-2xl backdrop:bg-transparent\"\n\tonpointerdown={pointerDownHandler}\n\tonkeydown={keydownHandler}\n\tonclose={() => {\n\t\t// There is no way to prevent dialog close event\n\t\tclose()\n\t}}\n>\n\t<div role=\"menu\" class=\"flex flex-col py-2\">\n\t\t{#each items as item}\n\t\t\t<button\n\t\t\t\t{@attach ripple()}\n\t\t\t\trole=\"menuitem\"\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass={[\n\t\t\t\t\t'interactable relative flex min-h-10 grow items-center px-4 py-2 text-left text-body-md -outline-offset-2 select-none',\n\t\t\t\t\titem.selected && 'bg-surfaceVariant text-primary',\n\t\t\t\t]}\n\t\t\t\tonclick={() => {\n\t\t\t\t\titem.action()\n\t\t\t\t\tclose()\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{item.label}\n\t\t\t</button>\n\t\t{/each}\n\t</div>\n</dialog>\n"
  },
  {
    "path": "src/lib/components/menu/MenuRenderer.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { createContext } from 'svelte'\n\timport { timeline } from '$lib/helpers/animations.ts'\n\timport { isElementTextInput } from '$lib/helpers/input.ts'\n\timport { assign } from '$lib/helpers/utils/assign.ts'\n\timport Menu from './Menu.svelte'\n\timport { getMeasurementsFromAnchor, positionMenu } from './positioning.ts'\n\timport type { MenuItem, MenuOptions, MenuPosition } from './types.ts'\n\n\texport interface MenuInternalData {\n\t\titems: MenuItem[]\n\t\ttargetElement: HTMLElement\n\t\toptions?: MenuOptions\n\t}\n\n\texport interface MenuInternalState {\n\t\tvalue?: MenuInternalData\n\t}\n\n\tconst [getMenuContext, setMenuContext] = createContext<MenuInternalState>()\n\n\texport const setupGlobalMenu = (): void => {\n\t\tconst menuState = $state<MenuInternalState>({\n\t\t\tvalue: undefined,\n\t\t})\n\n\t\tsetMenuContext(menuState)\n\t}\n\n\texport interface MenuAPI {\n\t\tshow: (items: MenuItem[], targetElement: HTMLElement, options: MenuOptions) => void\n\t\tshowFromEvent: (e: MouseEvent, items: MenuItem[], options: MenuOptions) => void\n\t}\n\n\texport const useMenu = (): MenuAPI => {\n\t\tconst state = getMenuContext()\n\n\t\tinvariant(state, 'useMenu must be used within a MenuProvider')\n\n\t\tconst showMenu: MenuAPI['show'] = (items, targetElement, options) => {\n\t\t\tassign(state, {\n\t\t\t\tvalue: {\n\t\t\t\t\titems,\n\t\t\t\t\ttargetElement,\n\t\t\t\t\toptions,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\tconst showMenuFromEvent: MenuAPI['showFromEvent'] = (e, items, options) => {\n\t\t\tconst { target } = e\n\n\t\t\tinvariant(target instanceof HTMLElement, 'target is not an HTMLElement')\n\n\t\t\tshowMenu(items, target, options)\n\t\t}\n\n\t\treturn {\n\t\t\tshow: showMenu,\n\t\t\tshowFromEvent: showMenuFromEvent,\n\t\t}\n\t}\n</script>\n\n<script lang=\"ts\">\n\tconst context = getMenuContext()\n\tconst data = $derived(context.value)\n\n\tlet closing = false\n\n\tconst openMenu = (menuEl: HTMLDialogElement) => {\n\t\tclosing = false\n\n\t\tinvariant(data, 'data is undefined')\n\n\t\tconst { options } = data\n\n\t\tmenuEl.showModal()\n\n\t\tif (options?.width) {\n\t\t\tmenuEl.style.width = `${options.width}px`\n\t\t}\n\t\tif (options?.height) {\n\t\t\tmenuEl.style.height = `${options.height}px`\n\t\t}\n\n\t\tconst baseRect = menuEl.getBoundingClientRect()\n\n\t\tconst rect = {\n\t\t\t...baseRect,\n\t\t\twidth: options?.width ?? baseRect.width,\n\t\t\theight: options?.height ?? baseRect.height,\n\t\t}\n\n\t\tlet position: MenuPosition\n\t\tif (options?.anchor) {\n\t\t\tposition = getMeasurementsFromAnchor(rect, data.targetElement, options.preferredAlignment)\n\t\t} else {\n\t\t\tposition = options?.position ?? {\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 0,\n\t\t\t}\n\t\t}\n\n\t\tpositionMenu(menuEl, {\n\t\t\t...rect,\n\t\t\t...position,\n\t\t})\n\n\t\ttimeline([\n\t\t\t[\n\t\t\t\tmenuEl,\n\t\t\t\t{\n\t\t\t\t\topacity: [0, 1],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 45,\n\t\t\t\t\teasing: 'linear',\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\tmenuEl,\n\t\t\t\t{\n\t\t\t\t\ttransform: ['scale(.8)', 'none'],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 150,\n\t\t\t\t\t// incoming 80\n\t\t\t\t\teasing: 'cubic-bezier(0, 0, 0.2, 1)',\n\t\t\t\t\tat: '<',\n\t\t\t\t},\n\t\t\t],\n\t\t])\n\t}\n\n\tconst closeMenu = (menuEl: Element) => {\n\t\t// If menu is already closed do nothing.\n\t\t// This can happen when keydown event occurs first\n\t\t// and then focusout fires after.\n\t\tif (!data || closing) {\n\t\t\treturn\n\t\t}\n\n\t\tclosing = true\n\n\t\tvoid menuEl\n\t\t\t.animate(\n\t\t\t\t{\n\t\t\t\t\topacity: [1, 0],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 100,\n\t\t\t\t\teasing: 'linear',\n\t\t\t\t},\n\t\t\t)\n\t\t\t.finished.then(() => {\n\t\t\t\t// Check if menu is still closing.\n\t\t\t\tif (!closing) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Save the element that opened the menu\n\t\t\t\tconst target = data.targetElement\n\t\t\t\tcontext.value = undefined\n\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Restore focus to the element that opened the menu\n\t\t\t\t\ttarget.focus({\n\t\t\t\t\t\tpreventScroll: true,\n\t\t\t\t\t})\n\t\t\t\t}, 0)\n\t\t\t})\n\t}\n\n\tconst globalContextMenuHandler = (e: MouseEvent) => {\n\t\tconst el = e.composedPath().at(0)\n\n\t\t// Allow standard browser context menu on input[type='text'] elements,\n\t\t// because creating custom menu for copy & paste\n\t\t// with working text selection is hard.\n\t\tif (isElementTextInput(el)) {\n\t\t\treturn\n\t\t}\n\n\t\te.preventDefault()\n\t}\n</script>\n\n<svelte:window oncontextmenu={globalContextMenuHandler} />\n\n{#if data}\n\t<Menu items={data.items} onopen={openMenu} onclose={closeMenu} />\n{/if}\n"
  },
  {
    "path": "src/lib/components/menu/positioning.ts",
    "content": "import { assign } from '$lib/helpers/utils/assign.ts'\nimport type { MenuAlignment, MenuPosition } from './types.ts'\n\nexport const getMeasurementsFromAnchor = (\n\tmenuRect: DOMRect,\n\tanchor: Element,\n\talign?: MenuAlignment,\n): {\n\ttop: number\n\tleft: number\n\toriginY: number\n\toriginX: number\n} => {\n\tconst { horizontal: horizontalAlign = 'left', vertical: verticalAlign = 'top' } = align || {}\n\n\tconst anchorRect = anchor.getBoundingClientRect()\n\tconst { top: aTop, left: aLeft } = anchorRect\n\n\tconst top = verticalAlign === 'top' ? aTop : anchorRect.bottom - menuRect.height\n\tconst left = horizontalAlign === 'left' ? aLeft : anchorRect.right - menuRect.width\n\n\tconst originY = Math.abs(aTop - top + anchorRect.height / 2)\n\tconst originX = Math.abs(aLeft - left + anchorRect.width / 2)\n\n\tconst position = {\n\t\ttop,\n\t\tleft,\n\t\toriginY,\n\t\toriginX,\n\t}\n\n\treturn position\n}\n\ninterface MenuPositioning extends MenuPosition {\n\toriginY?: number\n\toriginX?: number\n\twidth: number\n\theight: number\n}\n\nexport const positionMenu = (menuEl: HTMLElement, pos: MenuPositioning): void => {\n\t// Menu can't be placed outside of window bounds.\n\tconst top = Math.min(pos.top, window.innerHeight - pos.height)\n\tconst left = Math.min(pos.left, window.innerWidth - pos.width)\n\n\tassign(menuEl.style, {\n\t\ttop: `${top}px`,\n\t\tleft: `${left}px`,\n\t\ttransformOrigin: `${pos.originX || 0}px ${pos.originY || 0}px`,\n\t})\n}\n"
  },
  {
    "path": "src/lib/components/menu/types.ts",
    "content": "export interface MenuPosition {\n\ttop: number\n\tleft: number\n}\n\nexport interface MenuAlignment {\n\thorizontal?: 'left' | 'right'\n\tvertical?: 'top' | 'bottom'\n}\n\ninterface MenuAnchorOptions {\n\tanchor: true\n\tpreferredAlignment?: MenuAlignment\n}\n\ninterface MenuPositionOptions {\n\tanchor: false\n\tposition: MenuPosition\n}\n\ninterface MenuSize {\n\twidth?: number\n\theight?: number\n}\n\n/** @public */\nexport type MenuOptions = (MenuAnchorOptions | MenuPositionOptions) & MenuSize\n\n/** @public */\nexport interface MenuItem {\n\tlabel: string\n\tselected?: boolean\n\taction: () => void\n}\n"
  },
  {
    "path": "src/lib/components/player/MainControls.svelte",
    "content": "<script lang=\"ts\">\n\timport PlayNextButton from './buttons/PlayNextButton.svelte'\n\timport PlayPrevButton from './buttons/PlayPrevButton.svelte'\n\timport PlayTogglePillButton from './buttons/PlayTogglePillButton.svelte'\n\timport RepeatButton from './buttons/RepeatButton.svelte'\n\timport ShuffleButton from './buttons/ShuffleButton.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n</script>\n\n<div class={['flex items-center gap-2', className]}>\n\t<ShuffleButton />\n\n\t<PlayPrevButton />\n\n\t<PlayTogglePillButton />\n\n\t<PlayNextButton />\n\n\t<RepeatButton />\n</div>\n"
  },
  {
    "path": "src/lib/components/player/PlayerArtwork.svelte",
    "content": "<script lang=\"ts\">\n\timport Artwork from '../Artwork.svelte'\n\timport type { IconType } from '../icon/Icon.svelte'\n\n\tinterface Props {\n\t\tfallbackIcon?: IconType | null\n\t\tclass?: ClassValue\n\t\tchildren?: Snippet\n\t}\n\n\tconst { fallbackIcon, ...props }: Props = $props()\n\n\tconst player = usePlayer()\n</script>\n\n<Artwork\n\tsrc={player.artworkSrc}\n\talt={player.activeTrack?.name}\n\tfallbackIcon={player.activeTrack ? undefined : false}\n\tnoFallbackBg\n\t{...props}\n/>\n"
  },
  {
    "path": "src/lib/components/player/Timeline.svelte",
    "content": "<script lang=\"ts\">\n\timport { formatDuration } from '$lib/helpers/utils/format-duration.ts'\n\timport Slider from '../Slider.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n\n\tconst player = usePlayer()\n\n\tconst max = 1000\n\n\tlet seeking = $state(false)\n\tlet seekingValue = $state(0)\n\n\tconst value = $derived.by(() => {\n\t\tconst v = (player.currentTime / player.duration) * max\n\n\t\treturn Number.isFinite(v) ? v : 0\n\t})\n\n\tconst getTime = (percentage: number) => (percentage / max) * player.duration\n\n\tconst playerSeek = (val: number) => {\n\t\tplayer.seek(getTime(val))\n\t}\n\n\tconst currentTime = () => formatDuration(seeking ? getTime(seekingValue) : player.currentTime)\n\n\tconst getSliderValue = () => (seeking ? seekingValue : value)\n\tconst setSliderValue = (val: number) => {\n\t\tif (seeking) {\n\t\t\tseekingValue = val\n\t\t} else {\n\t\t\tplayerSeek(val)\n\t\t}\n\t}\n</script>\n\n<div\n\tclass={[\n\t\t'timeline-container grid w-full items-center gap-2.5 text-nowrap tabular-nums',\n\t\tclassName,\n\t]}\n>\n\t<div class=\"text-body-sm\">\n\t\t{currentTime()}\n\t</div>\n\n\t<Slider\n\t\tdisabled={!player.activeTrack}\n\t\t{max}\n\t\tbind:value={getSliderValue, setSliderValue}\n\t\tonSeekStart={() => {\n\t\t\tif (!seeking) {\n\t\t\t\tseekingValue = value\n\t\t\t}\n\n\t\t\tseeking = true\n\t\t}}\n\t\tonSeekEnd={() => {\n\t\t\tseeking = false\n\n\t\t\tplayerSeek(seekingValue)\n\t\t}}\n\t/>\n\n\t<div class=\"text-right text-body-sm\">\n\t\t{formatDuration(player.duration)}\n\t</div>\n</div>\n\n<style>\n\t.timeline-container {\n\t\tgrid-template-columns: minmax(32px, max-content) 1fr minmax(32px, max-content);\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/player/VolumeSlider.svelte",
    "content": "<script lang=\"ts\">\n\timport Slider from '../Slider.svelte'\n\n\tconst player = usePlayer()\n</script>\n\n<Slider bind:value={player.volume} />\n"
  },
  {
    "path": "src/lib/components/player/buttons/ActiveIndicator.svelte",
    "content": "<script lang=\"ts\">\n\tconst { active }: { active: boolean } = $props()\n</script>\n\n<div\n\tclass={[\n\t\t'absolute bottom-1 size-1 origin-center rounded-full bg-primary transition-transform duration-600',\n\t\tactive ? 'scale-100' : 'scale-0',\n\t]}\n></div>\n"
  },
  {
    "path": "src/lib/components/player/buttons/PlayNextButton.svelte",
    "content": "<script lang=\"ts\">\n\timport PlayPreviousNextIcon from '../../animated-icons/PlayPreviousNextIcon.svelte'\n\timport IconButton from '../../IconButton.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n\n\tconst player = usePlayer()\n</script>\n\n<IconButton\n\ttooltip={m.playerPlayNextTrack()}\n\tdisabled={player.isQueueEmpty}\n\tclass={className}\n\tonclick={player.playNext}\n>\n\t<PlayPreviousNextIcon type=\"next\" />\n</IconButton>\n"
  },
  {
    "path": "src/lib/components/player/buttons/PlayPrevButton.svelte",
    "content": "<script lang=\"ts\">\n\timport PlayPreviousNextIcon from '../../animated-icons/PlayPreviousNextIcon.svelte'\n\timport IconButton from '../../IconButton.svelte'\n\n\tconst player = usePlayer()\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n</script>\n\n<IconButton\n\ttooltip={m.playerPlayPreviousTrack()}\n\tdisabled={player.isQueueEmpty}\n\tclass={className}\n\tonclick={player.playPrev}\n>\n\t<PlayPreviousNextIcon type=\"previous\" />\n</IconButton>\n"
  },
  {
    "path": "src/lib/components/player/buttons/PlayToggleButton.svelte",
    "content": "<script lang=\"ts\">\n\timport PlayPauseIcon from '$lib/components/animated-icons/PlayPauseIcon.svelte'\n\timport IconButton from '../../IconButton.svelte'\n\n\tconst player = usePlayer()\n</script>\n\n<IconButton\n\ttooltip={player.playing ? m.playerPause() : m.playerPlay()}\n\tdisabled={!player.activeTrack}\n\tonclick={() => player.togglePlay()}\n>\n\t<PlayPauseIcon playing={player.playing} />\n</IconButton>\n"
  },
  {
    "path": "src/lib/components/player/buttons/PlayTogglePillButton.svelte",
    "content": "<script lang=\"ts\">\n\timport PlayPauseIcon from '../../animated-icons/PlayPauseIcon.svelte'\n\timport Button from '../../Button.svelte'\n\n\tconst player = usePlayer()\n</script>\n\n<Button\n\ttooltip={player.playing ? m.playerPause() : m.playerPlay()}\n\tclass=\"w-18 p-0!\"\n\tdisabled={!player.activeTrack}\n\tonclick={() => player.togglePlay()}\n>\n\t<PlayPauseIcon playing={player.playing} />\n</Button>\n"
  },
  {
    "path": "src/lib/components/player/buttons/PlayerFavoriteButton.svelte",
    "content": "<script lang=\"ts\">\n\timport FavoriteButton from '$lib/components/FavoriteButton.svelte'\n\n\tconst player = usePlayer()\n\n\tconst track = $derived(player.activeTrack)\n</script>\n\n{#if track}\n\t<FavoriteButton trackId={track.id} favorite={track.favorite} />\n{/if}\n"
  },
  {
    "path": "src/lib/components/player/buttons/RepeatButton.svelte",
    "content": "<script lang=\"ts\">\n\timport { on } from 'svelte/events'\n\timport type { PlayerRepeat } from '$lib/stores/player/player.svelte'\n\timport IconButton from '../../IconButton.svelte'\n\timport ActiveIndicator from './ActiveIndicator.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n\n\tconst player = usePlayer()\n\n\tconst action = (target: SVGSVGElement) => {\n\t\tlet button = target.parentElement\n\n\t\twhile (button) {\n\t\t\tif (button.tagName === 'BUTTON') {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tbutton = button.parentElement\n\t\t}\n\n\t\tinvariant(button, 'No button found')\n\n\t\tlet animation: Animation | null = null\n\t\tconst cleanup = on(button, 'click', () => {\n\t\t\tif ((button as HTMLButtonElement).disabled) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst arrows = target.querySelector('[data-arrows]')\n\t\t\tif (!arrows || animation) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tanimation = arrows.animate(\n\t\t\t\t{\n\t\t\t\t\ttransform: ['rotate(0deg)', 'rotate(180deg)'],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tduration: 300,\n\t\t\t\t\teasing: 'ease-out',\n\t\t\t\t\tfill: 'none',\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tanimation.onfinish = () => {\n\t\t\t\tanimation = null\n\t\t\t}\n\t\t})\n\n\t\treturn cleanup\n\t}\n\n\tconst tooltipMap: Record<PlayerRepeat, string> = {\n\t\tnone: m.playerEnableRepeat(),\n\t\tall: m.playerEnableRepeatOne(),\n\t\tone: m.playerDisableRepeat(),\n\t}\n</script>\n\n<IconButton tooltip={tooltipMap[player.repeat]} class={className} onclick={player.toggleRepeat}>\n\t<svg {@attach action} class=\"size-6 fill-current\" viewBox=\"0 0 24 24\">\n\t\t<path\n\t\t\tdata-arrows\n\t\t\tclass=\"origin-center\"\n\t\t\td=\"M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z\"\n\t\t/>\n\t\t<path\n\t\t\tclass={[\n\t\t\t\t'origin-center transition-transform',\n\t\t\t\tplayer.repeat === 'one' ? 'scale-100' : 'scale-0',\n\t\t\t]}\n\t\t\td=\"M 13,15 V 9.0000002 H 12 L 10,10 v 1 h 1.5 v 4 z\"\n\t\t/>\n\t</svg>\n\n\t<ActiveIndicator active={player.repeat !== 'none'} />\n</IconButton>\n"
  },
  {
    "path": "src/lib/components/player/buttons/ShuffleButton.svelte",
    "content": "<script lang=\"ts\">\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport IconButton from '../../IconButton.svelte'\n\timport ActiveIndicator from './ActiveIndicator.svelte'\n\n\tconst { class: className }: { class?: ClassValue } = $props()\n\n\tconst player = usePlayer()\n</script>\n\n<IconButton\n\ttooltip={player.shuffle ? m.playerDisableShuffle() : m.playerEnableShuffle()}\n\tclass={className}\n\tonclick={player.toggleShuffle}\n>\n\t<Icon type=\"shuffle\" />\n\n\t<ActiveIndicator active={player.shuffle} />\n</IconButton>\n"
  },
  {
    "path": "src/lib/components/playlists/PlaylistListContainer.svelte",
    "content": "<script lang=\"ts\" module>\n\timport type { Playlist } from '$lib/library/types.ts'\n\timport type { IconType } from '../icon/Icon.svelte'\n\timport VirtualContainer from '../VirtualContainer.svelte'\n\timport PlaylistListItem, { type MenuItemsConfig } from './PlaylistListItem.svelte'\n\n\texport interface TrackItemClick {\n\t\tplaylist: Playlist\n\t\titems: number[]\n\t\tindex: number\n\t}\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\titems: number[]\n\t\ticon?: Snippet<[Playlist]> | IconType\n\t\tonItemClick?: (data: TrackItemClick) => void\n\t\tmenuItems?: MenuItemsConfig\n\t}\n\n\tconst { items, icon, menuItems, onItemClick }: Props = $props()\n</script>\n\n<VirtualContainer size={56} count={items.length} key={(index) => `${items[index]}-${index}`}>\n\t{#snippet children(item)}\n\t\t{@const playlistId = items[item.index] as number}\n\n\t\t<PlaylistListItem\n\t\t\t{playlistId}\n\t\t\tstyle=\"transform: translateY({item.start}px)\"\n\t\t\tclass=\"virtual-item top-0 left-0 w-full\"\n\t\t\tariaRowIndex={item.index}\n\t\t\t{menuItems}\n\t\t\t{icon}\n\t\t\tonclick={(playlist) => {\n\t\t\t\tonItemClick?.({\n\t\t\t\t\tplaylist,\n\t\t\t\t\titems,\n\t\t\t\t\tindex: item.index,\n\t\t\t\t})\n\t\t\t}}\n\t\t/>\n\t{/snippet}\n</VirtualContainer>\n"
  },
  {
    "path": "src/lib/components/playlists/PlaylistListItem.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { createPlaylistQuery } from '$lib/library/get/value-queries.ts'\n\timport { FAVORITE_PLAYLIST_ID } from '$lib/library/playlists-actions'\n\timport type { Playlist } from '$lib/library/types.ts'\n\timport type { IconType } from '../icon/Icon.svelte'\n\timport Icon from '../icon/Icon.svelte'\n\timport ListItem from '../ListItem.svelte'\n\timport MenuButton from '../MenuButton.svelte'\n\timport type { MenuItem } from '../menu/types.ts'\n\n\texport type MenuItemsSelector = (playlist: Playlist) => MenuItem[]\n\texport type MenuItemsConfig =\n\t\t| {\n\t\t\t\tdisabled?: (playlist: Playlist) => boolean\n\t\t\t\titems: MenuItemsSelector\n\t\t  }\n\t\t| MenuItemsSelector\n</script>\n\n<script lang=\"ts\">\n\tinterface Props {\n\t\tplaylistId: number\n\t\tstyle?: string\n\t\tariaRowIndex: number\n\t\tactive?: boolean\n\t\tclass?: ClassValue\n\t\ticon?: Snippet<[Playlist]> | IconType\n\t\tmenuItems?: MenuItemsConfig\n\t\tonclick?: (playlist: Playlist) => void\n\t}\n\n\tconst {\n\t\tplaylistId,\n\t\tstyle,\n\t\tactive,\n\t\tclass: className,\n\t\tonclick,\n\t\ticon,\n\t\tariaRowIndex,\n\t\tmenuItems,\n\t}: Props = $props()\n\n\tconst data = createPlaylistQuery(() => playlistId)\n\tconst playlist = $derived(data.value)\n\n\tconst menuItemsWithItem = $derived.by(() => {\n\t\tif (!(playlist && menuItems)) {\n\t\t\treturn undefined\n\t\t}\n\n\t\tif (typeof menuItems === 'object') {\n\t\t\treturn menuItems.disabled?.(playlist) ? undefined : () => menuItems.items(playlist)\n\t\t}\n\n\t\treturn () => menuItems(playlist)\n\t})\n\n\tconst fallbackIcon = () => (playlistId === FAVORITE_PLAYLIST_ID ? 'favorite' : 'playlist')\n</script>\n\n<ListItem\n\t{style}\n\ttabindex={-1}\n\tclass={['h-14 text-left', active && 'bg-onSurfaceVariant/10 text-onSurfaceVariant', className]}\n\tariaLabel={`Play ${playlist?.name}`}\n\t{ariaRowIndex}\n\tonclick={() => {\n\t\tinvariant(playlist)\n\t\tonclick?.(playlist)\n\t}}\n>\n\t<div role=\"cell\" class=\"track-item grow items-center gap-5\">\n\t\t{#if typeof icon === 'function'}\n\t\t\t{#if playlist}\n\t\t\t\t{@render icon(playlist)}\n\t\t\t{/if}\n\t\t{:else}\n\t\t\t<div class=\"rounded-3xl bg-surfaceContainerHigh p-2 text-onSurfaceVariant/54\">\n\t\t\t\t<Icon type={icon ?? fallbackIcon()} />\n\t\t\t</div>\n\t\t{/if}\n\n\t\t{#if data.loading}\n\t\t\t<div>\n\t\t\t\t<div class=\"h-2 rounded-xs bg-onSurface/10\"></div>\n\t\t\t</div>\n\t\t{:else if data.error}\n\t\t\tError loading track\n\t\t{:else if playlist}\n\t\t\t<div class=\"flex flex-col truncate\">\n\t\t\t\t{playlist.name}\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n\n\t<MenuButton tabindex={-1} menuItems={menuItemsWithItem} />\n</ListItem>\n\n<style>\n\t.track-item {\n\t\t--grid-cols: auto 1fr;\n\t\tdisplay: grid;\n\t\tgrid-template-columns: var(--grid-cols);\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/snackbar/Snackbar.svelte",
    "content": "<script lang=\"ts\" module>\n\texport interface SnackbarButton {\n\t\tlabel: string\n\t\taction: () => void\n\t}\n\n\texport interface SnackbarSnippet<SnippetArg = unknown> {\n\t\ttype: 'snippet'\n\t\targ?: SnippetArg\n\t\tsnippet: Snippet<[SnippetArg]>\n\t}\n\n\texport interface SnackbarData<T = unknown> {\n\t\tid: string\n\t\tmessage: (() => string) | string\n\t\tduration?: number | false\n\t\tcontrols?: SnackbarButton | SnackbarSnippet<T> | false\n\t\tlayout?: 'row' | 'column'\n\t}\n\n\texport interface SnackbarProps extends SnackbarData {\n\t\tondismiss: (id: string) => void\n\t}\n</script>\n\n<script lang=\"ts\">\n\timport Button from '../Button.svelte'\n\n\tconst {\n\t\tid,\n\t\tmessage,\n\t\tlayout = 'row',\n\t\tduration = 3000,\n\t\tcontrols: controlsFromProps,\n\t\tondismiss,\n\t}: SnackbarProps = $props()\n\n\tconst controls = $derived(\n\t\tcontrolsFromProps ?? {\n\t\t\tlabel: m.dismiss(),\n\t\t\taction: () => {},\n\t\t},\n\t)\n\n\t$effect(() => {\n\t\tif (!duration) {\n\t\t\treturn\n\t\t}\n\n\t\tconst timeoutId = window.setTimeout(ondismiss, duration, id)\n\n\t\treturn () => {\n\t\t\tclearTimeout(timeoutId)\n\t\t}\n\t})\n</script>\n\n<div\n\tclass={[\n\t\t'flex min-h-13 items-center rounded-lg bg-inverseSurface py-1.5 pr-1.5 pl-4 text-inverseOnSurface',\n\t\tlayout === 'column' ? 'flex-col items-start' : 'gap-2',\n\t]}\n>\n\t{#if message}\n\t\t<div class=\"min-h-3 py-2\">\n\t\t\t{typeof message === 'function' ? message() : message}\n\t\t</div>\n\t{/if}\n\n\t{#if controls && 'type' in controls}\n\t\t{@render controls.snippet(controls.arg)}\n\t{:else if controls}\n\t\t<div class=\"ml-auto flex gap-2\">\n\t\t\t{#if controls}\n\t\t\t\t<Button\n\t\t\t\t\tkind=\"flat\"\n\t\t\t\t\tclass=\"text-inversePrimary!\"\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tondismiss(id)\n\t\t\t\t\t\tcontrols.action()\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{controls.label}\n\t\t\t\t</Button>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/lib/components/snackbar/SnackbarRenderer.svelte",
    "content": "<script lang=\"ts\">\n\timport { flip } from 'svelte/animate'\n\timport Snackbar from './Snackbar.svelte'\n\timport { snackbarItems } from './store.svelte.ts'\n\n\tconst dismissHandler = (id: string) => {\n\t\tconst index = snackbarItems.findIndex((item) => item.id === id)\n\n\t\tif (index !== -1) {\n\t\t\tsnackbarItems.splice(index, 1)\n\t\t}\n\t}\n</script>\n\n{#if snackbarItems.length > 0}\n\t{#each snackbarItems as item (item.id)}\n\t\t<div class=\"pointer-events-auto top-auto col-3\" animate:flip={{ duration: 140 }}>\n\t\t\t<Snackbar {...item} ondismiss={dismissHandler} />\n\t\t</div>\n\t{/each}\n{/if}\n"
  },
  {
    "path": "src/lib/components/snackbar/snackbar.ts",
    "content": "import type { SnackbarData } from './Snackbar.svelte'\nimport { snackbarItems } from './store.svelte.ts'\n\nexport type SnackbarOptions<T = unknown> = SnackbarData<T>\n\nconst showSnackbar = <const T>(newSnackbar: SnackbarOptions<T> | string): void => {\n\tlet newSnackbarNormalized: SnackbarData<T>\n\tif (typeof newSnackbar === 'string') {\n\t\tnewSnackbarNormalized = { id: newSnackbar, message: newSnackbar }\n\t} else {\n\t\tnewSnackbarNormalized = newSnackbar\n\t}\n\n\tconst index = snackbarItems.findIndex((snackbar) => snackbar.id === newSnackbarNormalized.id)\n\n\tif (index > -1) {\n\t\tsnackbarItems[index] = newSnackbarNormalized\n\t} else {\n\t\tsnackbarItems.push(newSnackbarNormalized)\n\t}\n}\n\n/** @public */\nexport const snackbar = <const T>(newSnackbar: SnackbarOptions<T> | string): void => {\n\tuntrack(() => showSnackbar(newSnackbar))\n}\n\nsnackbar.dismiss = (id: string): void => {\n\tconst index = snackbarItems.findIndex((snackbar) => snackbar.id === id)\n\tif (index > -1) {\n\t\tsnackbarItems.splice(index, 1)\n\t}\n}\n\nsnackbar.unexpectedError = (error: unknown) => {\n\tconsole.error('[SNACKBAR] Unexpected error', error)\n\tsnackbar({\n\t\tid: 'unexpected-error',\n\t\tmessage: m.errorUnexpected(),\n\t\tduration: 10_000,\n\t})\n}\n"
  },
  {
    "path": "src/lib/components/snackbar/store.svelte.ts",
    "content": "import type { SnackbarData } from './Snackbar.svelte'\n\n// biome-ignore lint/suspicious/noExplicitAny: this can be anything\nexport const snackbarItems: SnackbarData<any>[] = $state([])\n"
  },
  {
    "path": "src/lib/components/tracks/TrackListItem.svelte",
    "content": "<script lang=\"ts\">\n\timport { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte'\n\timport { formatDuration } from '$lib/helpers/utils/format-duration.ts'\n\timport { formatArtists, formatNameOrUnknown, getItemLanguage } from '$lib/helpers/utils/text.ts'\n\timport { createTrackQuery, type TrackData } from '$lib/library/get/value-queries.ts'\n\timport Artwork from '../Artwork.svelte'\n\timport FavoriteButton from '../FavoriteButton.svelte'\n\timport IconButton from '../IconButton.svelte'\n\timport Icon from '../icon/Icon.svelte'\n\timport ListItem from '../ListItem.svelte'\n\timport MenuButton from '../MenuButton.svelte'\n\timport type { MenuItem } from '../menu/types.ts'\n\n\tinterface Props {\n\t\ttrackId: number\n\t\tstyle?: string\n\t\tariaRowIndex: number\n\t\tactive: boolean\n\t\tactivePlaying: boolean\n\t\tclass?: ClassValue\n\t\tselectionEnabled: boolean\n\t\tselectionHover: boolean\n\t\tselected: boolean\n\t\tshowReorderButton?: boolean\n\t\tshowFavoriteButton?: boolean\n\t\treorderDragging?: boolean\n\t\treorderInsertBefore?: boolean\n\t\treorderInsertAfter?: boolean\n\t\tmenuItems?: (track: TrackData) => MenuItem[]\n\t\tonclick?: (track: TrackData, e: KeyboardEvent | MouseEvent) => void\n\t\tonpointerenter?: (e: PointerEvent) => void\n\t\tonReorderPointerDown?: (e: PointerEvent) => void\n\t\ttoggleSelection?: () => void\n\t}\n\n\tconst {\n\t\ttrackId,\n\t\tstyle,\n\t\tactive,\n\t\tactivePlaying,\n\t\tclass: className,\n\t\tselectionEnabled,\n\t\tselectionHover,\n\t\tselected,\n\t\tshowReorderButton = false,\n\t\tshowFavoriteButton = true,\n\t\treorderDragging = false,\n\t\treorderInsertBefore = false,\n\t\treorderInsertAfter = false,\n\t\tariaRowIndex: ariaRowIndexProp,\n\t\tmenuItems,\n\t\tonclick,\n\t\tonpointerenter,\n\t\tonReorderPointerDown,\n\t\ttoggleSelection,\n\t}: Props = $props()\n\n\t// ariaRowIndexProp rerenders a lot even when it doesn't change\n\tconst ariaRowIndex = $derived(ariaRowIndexProp)\n\n\tconst query = createTrackQuery(() => trackId)\n\tconst { value: track, loading } = $derived(query)\n\n\tconst artworkSrc = createManagedArtwork(() => track?.image?.small)\n\n\tconst menu = useMenu()\n\tconst menuItemsWithItem = $derived(track && menuItems?.bind(null, track))\n</script>\n\n<ListItem\n\t{style}\n\ttabindex={-1}\n\tclass={[\n\t\t'track-item-container group relative h-18 text-left',\n\t\tactive ? 'bg-onSurfaceVariant/10 text-onSurfaceVariant' : 'color-onSurfaceVariant',\n\t\tclassName,\n\t\tselected && 'bg-primary/5',\n\t\tselectionHover && 'bg-tertiary/10',\n\t\treorderDragging && 'bg-transparent',\n\t\treorderDragging && 'track-item-container-dragging',\n\t]}\n\tariaLabel={m.trackPlay({ name: track?.name ?? '' })}\n\t{ariaRowIndex}\n\tonclick={(e) => {\n\t\tif (track) {\n\t\t\tonclick?.(track, e)\n\t\t}\n\t}}\n\t{onpointerenter}\n\toncontextmenu={(e) => {\n\t\tif (!menuItemsWithItem) {\n\t\t\treturn\n\t\t}\n\n\t\te.preventDefault()\n\n\t\t// On mobile, enter selection mode instead of showing context menu\n\t\tif (e.pointerType === 'touch') {\n\t\t\tif (!selectionEnabled) {\n\t\t\t\t// Enter selection mode and select this item\n\t\t\t\ttoggleSelection?.()\n\t\t\t}\n\n\t\t\treturn\n\t\t}\n\n\t\tmenu.showFromEvent(e, menuItemsWithItem(), {\n\t\t\tanchor: false,\n\t\t\tposition: { top: e.y, left: e.x },\n\t\t})\n\t}}\n>\n\t{#if reorderInsertBefore || reorderInsertAfter}\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'pointer-events-none absolute right-2 left-2 z-20 h-0.5 bg-primary',\n\t\t\t\treorderInsertBefore ? 'top-0' : 'bottom-0',\n\t\t\t]}\n\t\t></div>\n\t{/if}\n\n\t<div role=\"gridcell\">\n\t\t<Artwork\n\t\t\tsrc={artworkSrc()}\n\t\t\talt={track?.name}\n\t\t\tclass={['mr-4 hidden! h-10 w-10 rounded-sm @xs:flex!', loading && 'opacity-50']}\n\t\t>\n\t\t\t{#if activePlaying}\n\t\t\t\t{@const barClassName = 'playing-bar h-5 w-[3px] origin-bottom rounded-sm bg-[white]'}\n\t\t\t\t<div class=\"absolute inset-0 flex items-center justify-center gap-1 bg-scrim/40\">\n\t\t\t\t\t<span class={barClassName}></span>\n\t\t\t\t\t<span class={[barClassName, '[--ani-delay:0.2s]']}></span>\n\t\t\t\t\t<span class={[barClassName, '[--ani-delay:0.4s]']}></span>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t</Artwork>\n\t</div>\n\n\t{#if loading}\n\t\t<div>\n\t\t\t<div class=\"mb-2 h-2 rounded-xs bg-onSurface/10\"></div>\n\t\t\t<div class=\"h-1 w-1/8 rounded-xs bg-onSurface/10\"></div>\n\t\t</div>\n\t{:else if query.error}\n\t\t<div class=\"text-error\">\n\t\t\tError loading track with id {trackId}\n\t\t</div>\n\t{:else if track}\n\t\t<div\n\t\t\trole=\"gridcell\"\n\t\t\tclass=\"track-item grow items-center gap-5\"\n\t\t\tlang={getItemLanguage(track.language)}\n\t\t>\n\t\t\t<div class=\"flex flex-col truncate\">\n\t\t\t\t<div class={[active ? 'text-primary' : 'color-onSurface', 'truncate']}>\n\t\t\t\t\t{track.name}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"truncate overflow-hidden\">\n\t\t\t\t\t{formatArtists(track.artists)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"hidden @3xl:block\">\n\t\t\t\t{formatNameOrUnknown(track.album)}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div role=\"gridcell\" class=\"flex gap-1\">\n\t\t\t{#if showReorderButton && !selectionEnabled}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\ttabindex={-1}\n\t\t\t\t\tclass=\"interactable flex size-11 shrink-0 touch-none items-center justify-center rounded-full\"\n\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t}}\n\t\t\t\t\tonpointerdown={onReorderPointerDown}\n\t\t\t\t>\n\t\t\t\t\t<Icon type=\"dragHorizontal\" />\n\t\t\t\t</button>\n\t\t\t{/if}\n\n\t\t\t{#if showFavoriteButton}\n\t\t\t\t<FavoriteButton\n\t\t\t\t\tclass={['hidden @sm:flex', selectionEnabled && 'invisible']}\n\t\t\t\t\ttrackId={track.id}\n\t\t\t\t\tfavorite={track.favorite}\n\t\t\t\t\ttabindex={-1}\n\t\t\t\t/>\n\t\t\t{/if}\n\n\t\t\t<MenuButton\n\t\t\t\tclass={[selectionEnabled && 'invisible']}\n\t\t\t\ttabindex={-1}\n\t\t\t\tmenuItems={menuItemsWithItem}\n\t\t\t/>\n\n\t\t\t<div\n\t\t\t\tclass={['items-center justify-end gap-1', selectionEnabled ? 'flex' : 'hidden @sm:flex']}\n\t\t\t>\n\t\t\t\t<div class=\"relative grid w-11 items-center justify-items-end\">\n\t\t\t\t\t{#if !selectionEnabled}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"text-right tabular-nums stack-in-grid group-focus-within:opacity-0 group-hover:opacity-0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{formatDuration(track.duration)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ttabindex={-1}\n\t\t\t\t\t\ttooltip={''}\n\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t'stack-in-grid',\n\t\t\t\t\t\t\tselectionEnabled\n\t\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t\t: 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100',\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\t\t// If selection is not enabled, enable it\n\t\t\t\t\t\t\t// otherwise let parent handle toggling\n\t\t\t\t\t\t\tif (!selectionEnabled) {\n\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\ttoggleSelection?.()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t\t'flex size-5 items-center justify-center rounded-sm border-2',\n\t\t\t\t\t\t\t\tselected && 'border-primary bg-primary text-onPrimary',\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{#if selected}\n\t\t\t\t\t\t\t\t<Icon type=\"check\" class=\"size-5\" />\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</IconButton>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t{/if}\n</ListItem>\n\n<style lang=\"postcss\">\n\t@reference '../../../app.css';\n\n\t:global(.track-item-container-dragging) [role='gridcell'] {\n\t\topacity: 25%;\n\t}\n\n\t.track-item {\n\t\t--grid-cols: 1fr;\n\t\tdisplay: grid;\n\t\tgrid-template-columns: var(--grid-cols);\n\t}\n\n\t@container (min-width: theme('container-3xl')) {\n\t\t.track-item {\n\t\t\t--grid-cols: 1.5fr minmax(200px, 1fr);\n\t\t}\n\t}\n\n\t@keyframes playing-bar {\n\t\t0%,\n\t\t100% {\n\t\t\ttransform: scaleY(0.3);\n\t\t}\n\t\t50% {\n\t\t\ttransform: scaleY(1);\n\t\t}\n\t}\n\n\t.playing-bar {\n\t\tanimation: playing-bar 0.8s ease-in-out infinite var(--ani-delay, 0s) backwards;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/tracks/TracksListContainer.svelte",
    "content": "<script lang=\"ts\" module>\n\timport { useSetOverlaySnippet } from '$lib/layout-bottom-bar.svelte.ts'\n\timport type { TrackData } from '$lib/library/get/value.ts'\n\timport Button from '../Button.svelte'\n\timport IconButton from '../IconButton.svelte'\n\timport MenuButton from '../MenuButton.svelte'\n\timport type { MenuItem } from '../menu/types.ts'\n\timport VirtualContainer from '../VirtualContainer.svelte'\n\timport TrackListItem from './TrackListItem.svelte'\n\timport { useTrackDragController } from './use-track-drag-controller.svelte.ts'\n\timport { type PredefinedTrackMenuItemOption, useTrackMenuItems } from './use-track-menu-items.ts'\n\timport { useTrackSelectionController } from './use-track-selection-controller.svelte.ts'\n\texport interface TrackItemClick {\n\t\ttrack: TrackData\n\t\titems: readonly number[]\n\t\tindex: number\n\t}\n\n\tinterface Props {\n\t\titems: readonly number[]\n\t\tpredefinedMenuItems?: Partial<Record<PredefinedTrackMenuItemOption, boolean>>\n\t\tmenuItems?: (track: TrackData, index: number) => MenuItem[]\n\t\tonItemClick?: (data: TrackItemClick) => void\n\t\tshowReorderButton?: boolean\n\t\tshowFavoriteButton?: boolean\n\t\tonReorder?: (fromIndex: number, toIndex: number) => void\n\t}\n</script>\n\n<script lang=\"ts\">\n\tconst player = usePlayer()\n\n\tconst defaultOnItemClick = (data: TrackItemClick) => {\n\t\tplayer.playTrack(data.index, data.items)\n\t}\n\n\tconst {\n\t\titems,\n\t\tmenuItems,\n\t\tpredefinedMenuItems = {},\n\t\tonItemClick = defaultOnItemClick,\n\t\tshowReorderButton = false,\n\t\tshowFavoriteButton = true,\n\t\tonReorder,\n\t}: Props = $props()\n\n\tconst { getMenuItems, getMultiSelectMenuItems } = useTrackMenuItems(\n\t\t() => menuItems,\n\t\t() => predefinedMenuItems,\n\t)\n\n\tconst selection = useTrackSelectionController({\n\t\titems: () => items,\n\t})\n\n\tconst dragController = useTrackDragController({\n\t\titemsCount: () => items.length,\n\t\tonReorder: (from, to) => onReorder?.(from, to),\n\t\tonStart: () => selection.cancelSelection(),\n\t})\n\n\t$effect(() => {\n\t\tvoid items\n\t\tvoid items.length\n\n\t\tuntrack(() => {\n\t\t\tselection.cancelSelection()\n\t\t})\n\t})\n\n\t$effect(() => () => dragController.stop())\n\n\tuseSetOverlaySnippet('above-player', () => (selection.selectionEnabled ? multiselectPane : null))\n</script>\n\n{#snippet multiselectPane()}\n\t<div\n\t\tclass=\"pointer-events-auto col-2 flex w-full items-center gap-1 rounded-lg bg-inverseSurface p-2 py-1 text-inverseOnSurface\"\n\t>\n\t\t<MenuButton\n\t\t\tmenuItems={() => getMultiSelectMenuItems(selection.selectedIds)}\n\t\t\talignment={{ horizontal: 'left', vertical: 'bottom' }}\n\t\t/>\n\n\t\t<div class=\"rounded-md bg-primary px-2 py-1\">\n\t\t\t{m.selectedCount({ count: selection.size })}\n\t\t</div>\n\n\t\t<Button\n\t\t\tkind=\"flat\"\n\t\t\tclass=\"ml-auto text-inversePrimary! disabled:text-inverseOnSurface/50!\"\n\t\t\tdisabled={selection.size === items.length}\n\t\t\tonclick={() => {\n\t\t\t\tselection.selectMany(items)\n\t\t\t}}\n\t\t>\n\t\t\t{m.selectAll()}\n\t\t</Button>\n\n\t\t<IconButton\n\t\t\ttooltip={m.libraryAddToPlaylist()}\n\t\t\ticon=\"close\"\n\t\t\tonclick={() => {\n\t\t\t\tselection.cancelSelection()\n\t\t\t}}\n\t\t/>\n\t</div>\n{/snippet}\n\n<VirtualContainer\n\tsize={72}\n\tcount={items.length}\n\tforceRenderIndexes={dragController.drag === null ? [] : [dragController.drag.fromIndex]}\n\tkey={(index) => `${items[index]}-${index}`}\n>\n\t{#snippet children(item)}\n\t\t{@const trackId = items[item.index] as number}\n\t\t{@const active = player.activeTrack?.id === trackId}\n\t\t{@const drag = dragController.drag}\n\n\t\t<TrackListItem\n\t\t\t{trackId}\n\t\t\t{active}\n\t\t\tactivePlaying={player.playing && active}\n\t\t\tstyle={`transform: translateY(${item.start}px)`}\n\t\t\tclass={[\n\t\t\t\t'virtual-item top-0 left-0 w-full',\n\t\t\t\tdrag !== null && 'no-drag-hover hover:bg-transparent!',\n\t\t\t]}\n\t\t\tariaRowIndex={item.index}\n\t\t\tselectionEnabled={selection.selectionEnabled}\n\t\t\tselectionHover={selection.isInHoverRange(item.index)}\n\t\t\tselected={selection.has(trackId)}\n\t\t\t{showReorderButton}\n\t\t\t{showFavoriteButton}\n\t\t\treorderDragging={drag?.fromIndex === item.index}\n\t\t\treorderInsertBefore={drag !== null && drag.insertIndex === item.index}\n\t\t\treorderInsertAfter={drag !== null && drag.insertIndex === item.index + 1}\n\t\t\tmenuItems={(track) => getMenuItems(track, item.index)}\n\t\t\tonclick={(track, e) => {\n\t\t\t\tselection.handleItemClick({\n\t\t\t\t\tevent: e,\n\t\t\t\t\ttrackId,\n\t\t\t\t\tindex: item.index,\n\t\t\t\t\tonClick: () => {\n\t\t\t\t\t\tonItemClick({\n\t\t\t\t\t\t\ttrack,\n\t\t\t\t\t\t\titems,\n\t\t\t\t\t\t\tindex: item.index,\n\t\t\t\t\t\t})\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}}\n\t\t\tonpointerenter={() => {\n\t\t\t\tif (dragController.drag === null) {\n\t\t\t\t\tselection.handlePointerEnter(item.index)\n\t\t\t\t}\n\t\t\t}}\n\t\t\ttoggleSelection={() => {\n\t\t\t\tselection.toggleSelection(trackId, item.index)\n\t\t\t}}\n\t\t\tonReorderPointerDown={(e) => {\n\t\t\t\tdragController.start(item.index, e)\n\t\t\t}}\n\t\t/>\n\t{/snippet}\n</VirtualContainer>\n\n{#if dragController.drag !== null}\n\t{@const drag = dragController.drag}\n\t{@const previewTrackId = items[drag.fromIndex]}\n\t{#if previewTrackId !== undefined}\n\t\t<div\n\t\t\tpopover=\"manual\"\n\t\t\tclass=\"drag-preview-popover @container opacity-80\"\n\t\t\tstyle={`top:${drag.preview.top}px;left:${drag.preview.left}px;width:${drag.preview.width}px;`}\n\t\t\t{@attach (el) => {\n\t\t\t\tel.showPopover()\n\t\t\t}}\n\t\t>\n\t\t\t<TrackListItem\n\t\t\t\ttrackId={previewTrackId}\n\t\t\t\tactive={player.activeTrack?.id === previewTrackId}\n\t\t\t\tactivePlaying={player.playing && player.activeTrack?.id === previewTrackId}\n\t\t\t\tclass=\"pointer-events-none bg-surfaceContainerHigh shadow-lg\"\n\t\t\t\tariaRowIndex={drag.fromIndex}\n\t\t\t\tselectionEnabled={selection.selectionEnabled}\n\t\t\t\tselectionHover={false}\n\t\t\t\tselected={selection.has(previewTrackId)}\n\t\t\t\tmenuItems={(track) => getMenuItems(track, drag.fromIndex)}\n\t\t\t\t{showReorderButton}\n\t\t\t\t{showFavoriteButton}\n\t\t\t\treorderDragging={false}\n\t\t\t\treorderInsertBefore={false}\n\t\t\t\treorderInsertAfter={false}\n\t\t\t/>\n\t\t</div>\n\t{/if}\n{/if}\n\n<style lang=\"postcss\">\n\t:global(.no-drag-hover .interactable) {\n\t\tpointer-events: none;\n\t}\n\n\t.drag-preview-popover {\n\t\tmargin: 0;\n\t\tpadding: 0;\n\t\tborder: none;\n\t\tbackground: transparent;\n\t\tposition: fixed;\n\t\tinset: auto;\n\t\toverflow: visible;\n\t\tpointer-events: none;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/tracks/selection.svelte.ts",
    "content": "import { SvelteSet } from 'svelte/reactivity'\n\nexport class SelectionTracker {\n\t#selectedIds: Set<number> = new SvelteSet<number>()\n\t#selectionEnabled = $state(false)\n\n\tget selectedIds() {\n\t\treturn Array.from(this.#selectedIds)\n\t}\n\n\tget selectionEnabled() {\n\t\treturn this.#selectionEnabled\n\t}\n\n\trangeAnchor: number | null = null\n\n\tenterSelectionMode() {\n\t\tthis.#selectionEnabled = true\n\t}\n\n\ttoggle(id: number, index: number) {\n\t\tif (this.#selectedIds.has(id)) {\n\t\t\tthis.#selectedIds.delete(id)\n\t\t} else {\n\t\t\tthis.#selectedIds.add(id)\n\t\t}\n\n\t\tthis.#selectionEnabled = this.#selectedIds.size > 0\n\t\tif (this.#selectionEnabled) {\n\t\t\tthis.rangeAnchor = index\n\t\t} else {\n\t\t\tthis.rangeAnchor = null\n\t\t}\n\t}\n\n\tselect(id: number, index: number) {\n\t\tthis.#selectedIds.add(id)\n\t\tthis.#selectionEnabled = true\n\t\tthis.rangeAnchor = index\n\t}\n\n\tunselect(id: number) {\n\t\tthis.#selectedIds.delete(id)\n\t\tthis.#selectionEnabled = this.#selectedIds.size > 0\n\t\tif (!this.#selectionEnabled) {\n\t\t\tthis.rangeAnchor = null\n\t\t}\n\t}\n\n\tselectMany(ids: readonly number[]) {\n\t\tfor (const id of ids) {\n\t\t\tthis.#selectedIds.add(id)\n\t\t}\n\n\t\tthis.#selectionEnabled = this.#selectedIds.size > 0\n\t}\n\n\tunselectMany(ids: readonly number[]) {\n\t\tfor (const id of ids) {\n\t\t\tthis.#selectedIds.delete(id)\n\t\t}\n\n\t\tthis.#selectionEnabled = this.#selectedIds.size > 0\n\t\tif (!this.#selectionEnabled) {\n\t\t\tthis.rangeAnchor = null\n\t\t}\n\t}\n\n\t/** Sets rangeAnchor only when there is no anchor yet (hover-preview entry point). */\n\tsetHoverAnchor(index: number) {\n\t\tif (this.rangeAnchor === null) {\n\t\t\tthis.rangeAnchor = index\n\t\t}\n\t}\n\n\t/** Clears rangeAnchor when no items are selected (Shift release with no selection). */\n\tclearHoverAnchor() {\n\t\tif (!this.#selectionEnabled) {\n\t\t\tthis.rangeAnchor = null\n\t\t}\n\t}\n\n\thas(id: number) {\n\t\treturn this.#selectedIds.has(id)\n\t}\n\n\tclear() {\n\t\tthis.#selectedIds.clear()\n\t\tthis.#selectionEnabled = false\n\t\tthis.rangeAnchor = null\n\t}\n\n\tget size() {\n\t\treturn this.#selectedIds.size\n\t}\n}\n"
  },
  {
    "path": "src/lib/components/tracks/use-track-drag-controller.svelte.ts",
    "content": "import { useScrollTarget } from '../ScrollContainer.svelte'\n\nconst EDGE_THRESHOLD = 84\nconst MAX_SCROLL_STEP = 30\n\ninterface DragState {\n\tfromIndex: number\n\tinsertIndex: number\n\tpreview: {\n\t\ttop: number\n\t\tleft: number\n\t\twidth: number\n\t}\n}\n\ninterface UseTrackDragControllerOptions {\n\titemsCount: () => number\n\tonReorder: ((from: number, to: number) => void) | undefined\n\tonStart?: () => void\n}\n\nexport const useTrackDragController = ({\n\titemsCount,\n\tonReorder,\n\tonStart,\n}: UseTrackDragControllerOptions) => {\n\tconst scrollTarget = useScrollTarget()\n\tlet drag = $state<DragState | null>(null)\n\n\tlet activePointerId: number | null = null\n\tlet pointerOffsetY = 0\n\tlet currentPointerY = 0\n\tlet dragItemCount = 0\n\tlet rafId: number | null = null\n\tlet abortController: AbortController | null = null\n\n\tlet scrollViewport = { top: 0, bottom: 0 }\n\n\tconst refreshScrollViewport = () => {\n\t\tconst target = scrollTarget.current\n\t\tif (target instanceof Window) {\n\t\t\tscrollViewport = { top: 0, bottom: target.innerHeight }\n\t\t\treturn\n\t\t}\n\t\tconst rect = target.getBoundingClientRect()\n\t\tscrollViewport = { top: rect.top, bottom: rect.bottom }\n\t}\n\n\t$effect(() => {\n\t\tconst target = scrollTarget.current\n\t\tconst observed = target instanceof Window ? document.documentElement : target\n\t\trefreshScrollViewport()\n\n\t\tconst observer = new ResizeObserver(refreshScrollViewport)\n\t\tobserver.observe(observed)\n\t\treturn () => observer.disconnect()\n\t})\n\n\tconst scrollLoop = () => {\n\t\tconst { top, bottom } = scrollViewport\n\n\t\tconst topDelta = top + EDGE_THRESHOLD - currentPointerY\n\t\tconst bottomDelta = currentPointerY - (bottom - EDGE_THRESHOLD)\n\n\t\tif (topDelta > 0) {\n\t\t\tscrollTarget.current.scrollBy(\n\t\t\t\t0,\n\t\t\t\t-Math.round((topDelta / EDGE_THRESHOLD) * MAX_SCROLL_STEP),\n\t\t\t)\n\t\t\trafId = requestAnimationFrame(scrollLoop)\n\t\t} else if (bottomDelta > 0) {\n\t\t\tscrollTarget.current.scrollBy(\n\t\t\t\t0,\n\t\t\t\tMath.round((bottomDelta / EDGE_THRESHOLD) * MAX_SCROLL_STEP),\n\t\t\t)\n\t\t\trafId = requestAnimationFrame(scrollLoop)\n\t\t} else {\n\t\t\trafId = null\n\t\t}\n\t}\n\n\tconst getInsertIndex = (x: number, y: number): number | null => {\n\t\tconst target = document.elementFromPoint(x, y)\n\t\tif (!(target instanceof Element)) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst row = target.closest('[aria-rowindex]')\n\t\tif (!(row instanceof HTMLElement)) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst index = Number(row.ariaRowIndex)\n\t\tif (!Number.isInteger(index) || index < 0 || index >= dragItemCount) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst rowRect = row.getBoundingClientRect()\n\t\tconst isAfterHalf = y >= rowRect.top + rowRect.height / 2\n\t\treturn Math.max(0, Math.min(dragItemCount, isAfterHalf ? index + 1 : index))\n\t}\n\n\tconst stop = () => {\n\t\tdrag = null\n\t\tactivePointerId = null\n\t\tif (rafId !== null) {\n\t\t\tcancelAnimationFrame(rafId)\n\t\t\trafId = null\n\t\t}\n\t\tabortController?.abort()\n\t\tabortController = null\n\t}\n\n\tconst start = (index: number, e: PointerEvent) => {\n\t\tconst count = itemsCount()\n\t\tif (!onReorder || index < 0 || index >= count) {\n\t\t\treturn\n\t\t}\n\n\t\te.preventDefault()\n\t\te.stopPropagation()\n\n\t\tconst rowElement = (e.currentTarget as HTMLElement | null)?.closest('[aria-rowindex]')\n\t\tif (!(rowElement instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\n\t\tstop()\n\n\t\tonStart?.()\n\n\t\tconst rowRect = rowElement.getBoundingClientRect()\n\t\tpointerOffsetY = e.clientY - rowRect.top\n\t\tactivePointerId = e.pointerId\n\t\tdragItemCount = count\n\n\t\tdrag = {\n\t\t\tfromIndex: index,\n\t\t\tinsertIndex: index,\n\t\t\tpreview: { top: rowRect.top, left: rowRect.left, width: rowRect.width },\n\t\t}\n\n\t\tabortController = new AbortController()\n\n\t\tconst onMove = (event: PointerEvent) => {\n\t\t\tif (event.pointerId !== activePointerId || !drag) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\n\t\t\tdrag.preview.top = event.clientY - pointerOffsetY\n\t\t\tcurrentPointerY = event.clientY\n\t\t\tif (rafId === null) {\n\t\t\t\trafId = requestAnimationFrame(scrollLoop)\n\t\t\t}\n\n\t\t\tconst newInsertIndex = getInsertIndex(event.clientX, event.clientY)\n\t\t\tif (newInsertIndex !== null) {\n\t\t\t\tdrag.insertIndex = newInsertIndex\n\t\t\t}\n\t\t}\n\n\t\tconst onEnd = (event: PointerEvent) => {\n\t\t\tif (event.pointerId !== activePointerId || !drag) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst from = drag.fromIndex\n\t\t\tconst insertIndex = drag.insertIndex\n\t\t\tstop()\n\n\t\t\t// insertIndex is a slot *between* items; when the item moved downward the\n\t\t\t// slot index is one ahead of the target item index, so subtract 1.\n\t\t\tconst to = insertIndex > from ? insertIndex - 1 : insertIndex\n\t\t\tif (to !== from) {\n\t\t\t\tonReorder(from, to)\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('pointermove', onMove, {\n\t\t\tpassive: false,\n\t\t\tsignal: abortController.signal,\n\t\t})\n\t\twindow.addEventListener('pointerup', onEnd, { signal: abortController.signal })\n\t\twindow.addEventListener('pointercancel', onEnd, { signal: abortController.signal })\n\t}\n\n\treturn {\n\t\tget drag() {\n\t\t\treturn drag\n\t\t},\n\t\tstart,\n\t\tstop,\n\t}\n}\n"
  },
  {
    "path": "src/lib/components/tracks/use-track-menu-items.ts",
    "content": "import { goto } from '$app/navigation'\nimport { resolve } from '$app/paths'\nimport { getDatabase } from '$lib/db/database.ts'\nimport type { TrackData } from '$lib/library/get/value'\nimport { toggleFavoriteTrack } from '$lib/library/playlists-actions'\nimport type { MenuItem } from '../menu/types.ts'\n\nexport type PredefinedTrackMenuItemOption =\n\t| 'disableAddToQueue'\n\t| 'disableAddToPlaylist'\n\t| 'disableRemoveFromLibrary'\n\t| 'disableAddToFavorites'\n\t| 'disableViewAlbum'\n\t| 'disableViewArtist'\n\t| 'enableMultiRemoveFromFavorites'\n\ninterface PredefinedMenuItem extends MenuItem {\n\tpredefinedKey: PredefinedTrackMenuItemOption\n}\n\ntype FalsyValue = false | undefined | null | ''\n\ntype UnfilteredPredefinedMenuItem = PredefinedMenuItem | FalsyValue\n\nconst viewRelated = async (store: 'albums' | 'artists', name: string) => {\n\ttry {\n\t\tconst db = await getDatabase()\n\t\tconst album = await db.getFromIndex(store, 'name', name)\n\t\tinvariant(album)\n\n\t\tconst path = resolve('/(app)/library/[[slug=libraryEntities]]/[uuid]', {\n\t\t\tslug: store,\n\t\t\tuuid: album.uuid,\n\t\t})\n\n\t\tawait goto(path)\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nexport const useTrackMenuItems = (\n\tgetMenuItemsFn: () => ((track: TrackData, index: number) => MenuItem[]) | null | undefined,\n\tpredefinedItemsOptions: () => Partial<Record<PredefinedTrackMenuItemOption, boolean>>,\n) => {\n\tconst dialogs = useDialogsStore()\n\tconst player = usePlayer()\n\n\tconst filterPredefinedItems = (items: UnfilteredPredefinedMenuItem[]) => {\n\t\tconst options = predefinedItemsOptions()\n\t\tconst predefinedItems = items.filter((item) => {\n\t\t\tif (!item) {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tconst valueFromOptions = options[item.predefinedKey]\n\n\t\t\tif (item.predefinedKey.startsWith('disable')) {\n\t\t\t\treturn valueFromOptions === undefined ? true : !valueFromOptions\n\t\t\t}\n\n\t\t\treturn valueFromOptions ?? false\n\t\t}) as MenuItem[]\n\n\t\treturn predefinedItems\n\t}\n\n\tconst getMenuItems = (track: TrackData, index: number) => {\n\t\tconst albumName = track.album\n\t\t// In a future we should handle ability to view multiple artists\n\t\tconst artistName = track.artists[0]\n\n\t\tconst predefinedItems: UnfilteredPredefinedMenuItem[] = [\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToPlaylist',\n\t\t\t\tlabel: m.libraryAddToPlaylist(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('addToPlaylist', [track.id])\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToFavorites',\n\t\t\t\tlabel: track.favorite ? m.trackRemoveFromFavorites() : m.trackAddToFavorites(),\n\t\t\t\taction: () => {\n\t\t\t\t\tvoid toggleFavoriteTrack(track.favorite, track.id)\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToQueue',\n\t\t\t\tlabel: m.playerAddToQueue(),\n\t\t\t\taction: () => {\n\t\t\t\t\tplayer.addToQueue(track.id)\n\t\t\t\t},\n\t\t\t},\n\t\t\talbumName && {\n\t\t\t\tpredefinedKey: 'disableViewAlbum',\n\t\t\t\tlabel: m.trackViewAlbum(),\n\t\t\t\taction: () => {\n\t\t\t\t\tvoid viewRelated('albums', albumName)\n\t\t\t\t},\n\t\t\t},\n\t\t\tartistName && {\n\t\t\t\tpredefinedKey: 'disableViewArtist',\n\t\t\t\tlabel: m.trackViewArtist(),\n\t\t\t\taction: () => {\n\t\t\t\t\tvoid viewRelated('artists', artistName)\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableRemoveFromLibrary',\n\t\t\t\tlabel: m.libraryRemoveFromLibrary(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('removeFromLibrary', {\n\t\t\t\t\t\ttype: 'single',\n\t\t\t\t\t\tname: track.name,\n\t\t\t\t\t\tid: track.id,\n\t\t\t\t\t\tstoreName: 'tracks',\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\n\t\tconst menuItems = getMenuItemsFn()\n\n\t\treturn [\n\t\t\t...filterPredefinedItems(predefinedItems),\n\t\t\t...(menuItems ? menuItems(track, index) : []),\n\t\t]\n\t}\n\n\tconst getMultiSelectMenuItems = (trackIds: readonly number[]) => {\n\t\tconst predefinedItems: UnfilteredPredefinedMenuItem[] = [\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToPlaylist',\n\t\t\t\tlabel: m.libraryAddToPlaylist(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('addToPlaylist', trackIds)\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToFavorites',\n\t\t\t\tlabel: m.trackAddToFavorites(),\n\t\t\t\taction: () => {\n\t\t\t\t\ttrackIds.forEach((trackId) => {\n\t\t\t\t\t\tvoid toggleFavoriteTrack(false, trackId)\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableAddToQueue',\n\t\t\t\tlabel: m.playerAddToQueue(),\n\t\t\t\taction: () => {\n\t\t\t\t\tplayer.addToQueue(trackIds)\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'enableMultiRemoveFromFavorites',\n\t\t\t\tlabel: m.trackRemoveFromFavorites(),\n\t\t\t\taction: () => {\n\t\t\t\t\ttrackIds.forEach((trackId) => {\n\t\t\t\t\t\tvoid toggleFavoriteTrack(true, trackId)\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tpredefinedKey: 'disableRemoveFromLibrary',\n\t\t\t\tlabel: m.libraryRemoveFromLibrary(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('removeFromLibrary', {\n\t\t\t\t\t\ttype: 'multiple',\n\t\t\t\t\t\tids: trackIds,\n\t\t\t\t\t\tstoreName: 'tracks',\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\n\t\treturn filterPredefinedItems(predefinedItems)\n\t}\n\n\treturn {\n\t\tgetMenuItems,\n\t\tgetMultiSelectMenuItems,\n\t}\n}\n"
  },
  {
    "path": "src/lib/components/tracks/use-track-selection-controller.svelte.ts",
    "content": "import { isPrimaryModifierKey } from '$lib/helpers/utils/ua.ts'\nimport { SelectionTracker } from './selection.svelte.ts'\n\ninterface SelectionInteractionState {\n\thoverRangeEnd: number | null\n\tisShiftActive: boolean\n}\n\ninterface UseTrackSelectionControllerOptions {\n\titems: () => readonly number[]\n}\n\ninterface HandleItemClickOptions {\n\tevent: MouseEvent | KeyboardEvent\n\ttrackId: number\n\tindex: number\n\tonClick: () => void\n}\n\nexport const useTrackSelectionController = ({ items }: UseTrackSelectionControllerOptions) => {\n\tconst selection = new SelectionTracker()\n\n\tconst state: SelectionInteractionState = $state({\n\t\thoverRangeEnd: null,\n\t\tisShiftActive: false,\n\t})\n\n\tconst cancelSelection = () => {\n\t\tselection.clear()\n\t\tstate.hoverRangeEnd = null\n\t}\n\n\t$effect(() => {\n\t\tconst ac = new AbortController()\n\t\tconst { signal } = ac\n\n\t\tdocument.addEventListener(\n\t\t\t'keydown',\n\t\t\t(e: KeyboardEvent) => {\n\t\t\t\tif (e.key === 'Shift') {\n\t\t\t\t\tstate.isShiftActive = true\n\t\t\t\t}\n\n\t\t\t\tif (!selection.selectionEnabled) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif (e.key === 'Escape') {\n\t\t\t\t\tcancelSelection()\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif (e.key === 'a' && isPrimaryModifierKey(e)) {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tselection.selectMany(items())\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ signal },\n\t\t)\n\n\t\tdocument.addEventListener(\n\t\t\t'keyup',\n\t\t\t(e: KeyboardEvent) => {\n\t\t\t\tif (e.key === 'Shift') {\n\t\t\t\t\tstate.isShiftActive = false\n\t\t\t\t\tselection.clearHoverAnchor()\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ signal },\n\t\t)\n\n\t\treturn () => ac.abort()\n\t})\n\n\tconst isInHoverRange = (index: number) => {\n\t\tif (!state.isShiftActive || state.hoverRangeEnd === null) {\n\t\t\treturn false\n\t\t}\n\n\t\tconst anchor = selection.rangeAnchor\n\t\tif (anchor === null) {\n\t\t\treturn false\n\t\t}\n\n\t\tconst min = Math.min(anchor, state.hoverRangeEnd)\n\t\tconst max = Math.max(anchor, state.hoverRangeEnd)\n\n\t\treturn index >= min && index <= max\n\t}\n\n\tconst handlePointerEnter = (index: number) => {\n\t\tif (state.isShiftActive || selection.selectionEnabled) {\n\t\t\tstate.hoverRangeEnd = index\n\n\t\t\tif (state.isShiftActive) {\n\t\t\t\tselection.setHoverAnchor(index)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst applyShiftClick = (trackId: number, index: number) => {\n\t\tif (!selection.selectionEnabled) {\n\t\t\tselection.enterSelectionMode()\n\t\t}\n\n\t\tif (selection.rangeAnchor === null) {\n\t\t\tselection.select(trackId, index)\n\t\t\treturn\n\t\t}\n\n\t\tconst allItems = items()\n\t\tconst min = Math.min(selection.rangeAnchor, index)\n\t\tconst max = Math.max(selection.rangeAnchor, index)\n\t\tconst rangeIds: number[] = []\n\n\t\tlet allSelected = true\n\t\tfor (let i = min; i <= max; i += 1) {\n\t\t\tconst itemAtIndex = allItems[i]\n\t\t\tif (itemAtIndex === undefined) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\trangeIds.push(itemAtIndex)\n\t\t\tif (allSelected && !selection.has(itemAtIndex)) {\n\t\t\t\tallSelected = false\n\t\t\t}\n\t\t}\n\n\t\tif (allSelected) {\n\t\t\tselection.unselectMany(rangeIds)\n\t\t} else {\n\t\t\tselection.selectMany(rangeIds)\n\t\t}\n\n\t\tselection.rangeAnchor = index\n\t}\n\n\tconst handleItemClick = ({ event, trackId, index, onClick }: HandleItemClickOptions) => {\n\t\tif (isPrimaryModifierKey(event)) {\n\t\t\tevent.preventDefault()\n\t\t\tselection.toggle(trackId, index)\n\t\t\treturn\n\t\t}\n\n\t\tif (event.shiftKey) {\n\t\t\tevent.preventDefault()\n\t\t\tapplyShiftClick(trackId, index)\n\t\t\treturn\n\t\t}\n\n\t\tif (selection.selectionEnabled) {\n\t\t\tselection.toggle(trackId, index)\n\t\t\treturn\n\t\t}\n\n\t\tonClick()\n\t}\n\n\treturn {\n\t\tget selectionEnabled() {\n\t\t\treturn selection.selectionEnabled\n\t\t},\n\t\tget selectedIds() {\n\t\t\treturn selection.selectedIds\n\t\t},\n\t\tget size() {\n\t\t\treturn selection.size\n\t\t},\n\t\thas: (trackId: number) => selection.has(trackId),\n\t\tselectMany: (trackIds: readonly number[]) => selection.selectMany(trackIds),\n\t\ttoggleSelection: (trackId: number, index: number) => selection.toggle(trackId, index),\n\t\tcancelSelection,\n\t\tisInHoverRange,\n\t\thandlePointerEnter,\n\t\thandleItemClick,\n\t}\n}\n"
  },
  {
    "path": "src/lib/db/database.ts",
    "content": "import type { DBSchema, IDBPDatabase, IDBPObjectStore, IndexNames, StoreNames } from 'idb'\nimport { openDB } from 'idb'\nimport type {\n\tAlbum,\n\tArtist,\n\tDirectory,\n\tPlayHistoryEntry,\n\tPlaylist,\n\tPlaylistEntry,\n\tTrack,\n} from '$lib/library/types.ts'\nimport type { DbBaseChange, DbStandardChange } from './events.ts'\n\nexport interface AppDB extends DBSchema {\n\ttracks: {\n\t\tkey: number\n\t\tvalue: Track\n\t\tindexes: Pick<\n\t\t\tTrack,\n\t\t\t| 'uuid'\n\t\t\t| 'name'\n\t\t\t| 'album'\n\t\t\t| 'year'\n\t\t\t| 'duration'\n\t\t\t| 'artists'\n\t\t\t| 'directory'\n\t\t\t| 'fileName'\n\t\t\t| 'scannedAt'\n\t\t> & {\n\t\t\tpath: [directoryId: number, fileName: string]\n\t\t\tbyAlbumSorted: [album: string, name: string, trackNo: number, discNo: number]\n\t\t}\n\t\tmeta: {\n\t\t\toperations: DbStandardChange<'tracks'>\n\t\t}\n\t}\n\talbums: {\n\t\tkey: number\n\t\tvalue: Album\n\t\tindexes: Pick<Album, 'uuid' | 'name' | 'artists' | 'year'>\n\t\tmeta: {\n\t\t\toperations: DbStandardChange<'albums'>\n\t\t}\n\t}\n\tartists: {\n\t\tkey: number\n\t\tvalue: Artist\n\t\tindexes: Pick<Artist, 'uuid' | 'name'>\n\t\tmeta: {\n\t\t\toperations: DbStandardChange<'artists'>\n\t\t}\n\t}\n\tplaylists: {\n\t\tkey: number\n\t\tvalue: Playlist\n\t\tindexes: Pick<Playlist, 'uuid' | 'name' | 'createdAt'>\n\t\tmeta: {\n\t\t\toperations: DbStandardChange<'playlists'>\n\t\t}\n\t}\n\tplaylistEntries: {\n\t\tkey: number\n\t\tvalue: PlaylistEntry\n\t\tindexes: Pick<PlaylistEntry, 'playlistId' | 'trackId' | 'addedAt'> & {\n\t\t\tplaylistTrack: [playlistId: number, trackId: number]\n\t\t}\n\t\tmeta: {\n\t\t\toperations:\n\t\t\t\t| DbBaseChange<'playlistEntries', 'add', true>\n\t\t\t\t| DbBaseChange<'playlistEntries', 'delete', true>\n\t\t}\n\t}\n\tdirectories: {\n\t\tkey: number\n\t\tvalue: Directory\n\t\tindexes: Pick<Directory, 'id'>\n\t\tmeta: {\n\t\t\toperations: DbStandardChange<'directories'>\n\t\t}\n\t}\n\tplayHistory: {\n\t\tkey: number\n\t\tvalue: PlayHistoryEntry\n\t\tindexes: Pick<PlayHistoryEntry, 'trackId' | 'playedAt'>\n\t\tmeta: {\n\t\t\toperations: {\n\t\t\t\tstoreName: 'playHistory'\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport type AppStoreNames = StoreNames<AppDB>\nexport type AppIndexNames<Store extends AppStoreNames> = IndexNames<AppDB, Store>\n\nconst createIndexes = <Name extends AppStoreNames>(\n\tstore: IDBPObjectStore<AppDB, ArrayLike<AppStoreNames>, Name, 'versionchange'>,\n\tindexes: readonly AppIndexNames<Name>[],\n\toptions: IDBIndexParameters = {},\n) => {\n\tfor (const name of indexes) {\n\t\tstore.createIndex(name, name, options)\n\t}\n}\n\nconst createStore = <DBTypes extends DBSchema | unknown, Name extends StoreNames<DBTypes>>(\n\tdb: IDBPDatabase<DBTypes>,\n\tstoreName: Name,\n) =>\n\tdb.createObjectStore(storeName, {\n\t\tkeyPath: 'id',\n\t\tautoIncrement: true,\n\t})\n\nconst openAppDatabase = () =>\n\topenDB<AppDB>('snae-app-data', 3, {\n\t\tasync upgrade(db, oldVersion, _newVersion, tx) {\n\t\t\tconst { objectStoreNames } = db\n\n\t\t\tif (!objectStoreNames.contains('tracks')) {\n\t\t\t\tconst store = createStore(db, 'tracks')\n\n\t\t\t\tcreateIndexes(store, ['uuid'], { unique: true })\n\t\t\t\tcreateIndexes(\n\t\t\t\t\tstore,\n\t\t\t\t\t['name', 'album', 'year', 'duration', 'scannedAt', 'directory'],\n\t\t\t\t\t{\n\t\t\t\t\t\tunique: false,\n\t\t\t\t\t},\n\t\t\t\t)\n\n\t\t\t\tstore.createIndex('path', ['directory', 'fileName'], {\n\t\t\t\t\t// We keep flat folder structure in the database\n\t\t\t\t\t// but in actual FS multiple files with same name\n\t\t\t\t\t// can exist in different directories\n\t\t\t\t\tunique: false,\n\t\t\t\t})\n\n\t\t\t\tstore.createIndex('artists', 'artists', {\n\t\t\t\t\tunique: false,\n\t\t\t\t\tmultiEntry: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst tracksStore = tx.objectStore('tracks')\n\t\t\tif (!tracksStore.indexNames.contains('byAlbumSorted')) {\n\t\t\t\ttx.objectStore('tracks').createIndex(\n\t\t\t\t\t'byAlbumSorted',\n\t\t\t\t\t['album', 'discNo', 'trackNo', 'name'],\n\t\t\t\t\t{\n\t\t\t\t\t\tunique: false,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tif (oldVersion === 1) {\n\t\t\t\t// Previous versions didn't have discNo and trackNo fields\n\t\t\t\tfor await (const cursor of tracksStore) {\n\t\t\t\t\tconst track = cursor.value\n\t\t\t\t\tif (track.discNo === undefined || track.trackNo === undefined) {\n\t\t\t\t\t\tawait cursor.update({\n\t\t\t\t\t\t\t...track,\n\t\t\t\t\t\t\tdiscNo: track.discNo ?? 0,\n\t\t\t\t\t\t\tdiscOf: track.discOf ?? 0,\n\t\t\t\t\t\t\ttrackNo: track.trackNo ?? 0,\n\t\t\t\t\t\t\ttrackOf: track.trackOf ?? 0,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('albums')) {\n\t\t\t\tconst store = createStore(db, 'albums')\n\n\t\t\t\tcreateIndexes(store, ['name', 'uuid'], { unique: true })\n\t\t\t\tcreateIndexes(store, ['year'])\n\n\t\t\t\tstore.createIndex('artists', 'artists', {\n\t\t\t\t\tunique: false,\n\t\t\t\t\tmultiEntry: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('artists')) {\n\t\t\t\tconst store = createStore(db, 'artists')\n\t\t\t\tcreateIndexes(store, ['name', 'uuid'], { unique: true })\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('playlists')) {\n\t\t\t\tconst store = createStore(db, 'playlists')\n\t\t\t\tcreateIndexes(store, ['uuid'], { unique: true })\n\t\t\t\tcreateIndexes(store, ['name', 'createdAt'])\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('playlistEntries')) {\n\t\t\t\tconst store = db.createObjectStore('playlistEntries', {\n\t\t\t\t\tkeyPath: 'id',\n\t\t\t\t\tautoIncrement: true,\n\t\t\t\t})\n\n\t\t\t\tcreateIndexes(store, ['playlistId', 'trackId', 'addedAt'])\n\n\t\t\t\tstore.createIndex('playlistTrack', ['playlistId', 'trackId'])\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('directories')) {\n\t\t\t\tcreateStore(db, 'directories')\n\t\t\t}\n\n\t\t\tif (!objectStoreNames.contains('playHistory')) {\n\t\t\t\tconst store = createStore(db, 'playHistory')\n\t\t\t\tcreateIndexes(store, ['trackId'], { unique: true })\n\t\t\t\tcreateIndexes(store, ['playedAt'])\n\t\t\t}\n\t\t},\n\t})\n\ntype AppIDBDatabase = IDBPDatabase<AppDB>\nlet dbPromise: Promise<AppIDBDatabase> | AppIDBDatabase | null = null\n\nexport const getDatabase = (): Promise<AppIDBDatabase> | AppIDBDatabase => {\n\tif (dbPromise !== null) {\n\t\treturn dbPromise\n\t}\n\n\tdbPromise = openAppDatabase()\n\n\tdbPromise\n\t\t.then((db) => {\n\t\t\tdb.onclose = () => {\n\t\t\t\tdbPromise = null\n\t\t\t}\n\n\t\t\t// Micro optimization to avoid unwrapping the promise\n\t\t\tdbPromise = db\n\t\t})\n\t\t.catch(() => {\n\t\t\tdbPromise = null\n\t\t})\n\n\treturn dbPromise\n}\n\nexport type DbKey<Name extends AppStoreNames> = AppDB[Name]['key']\nexport type DbValue<Name extends AppStoreNames> = AppDB[Name]['value']\n"
  },
  {
    "path": "src/lib/db/events.ts",
    "content": "import type { AppDB, AppStoreNames } from './database.ts'\n\nexport type DbBaseChange<\n\tStoreName extends AppStoreNames,\n\tOperation extends 'add' | 'update' | 'delete' | (string & {}),\n\tIncludeValue extends boolean = false,\n> = {\n\tstoreName: StoreName\n\toperation: Operation\n\tkey: AppDB[StoreName]['key']\n} & (IncludeValue extends true\n\t? {\n\t\t\tvalue: AppDB[StoreName]['value']\n\t\t}\n\t: unknown)\n\nexport type DbStandardChange<\n\tStoreName extends AppStoreNames,\n\tIncludeValue extends boolean = false,\n> =\n\t| DbBaseChange<StoreName, 'add', IncludeValue>\n\t| DbBaseChange<StoreName, 'update', IncludeValue>\n\t| DbBaseChange<StoreName, 'delete', IncludeValue>\n\nexport type DatabaseChangeDetails = {\n\t[StoreName in AppStoreNames]: AppDB[StoreName]['meta']['operations']\n}[AppStoreNames]\n\nexport type DatabaseChangeDetailsList = readonly DatabaseChangeDetails[]\n\n// We need to notify our local frame and all other frames about database changes.\n// Including web workers, other tabs, etc.\nconst crossChannel = new BroadcastChannel('db-changes')\nconst localChannel = new EventTarget()\n\ntype Listener = (changes: readonly DatabaseChangeDetails[]) => void\n\n// It is faster to manually store listeners in a Set, than registering 2 EventTargets.\nconst listeners = new Set<Listener>()\n\n// We only want listeners to be registered in the main thread.\nif (globalThis.window) {\n\tconst notifyListeners = (changes: readonly DatabaseChangeDetails[]) => {\n\t\tfor (const listener of listeners) {\n\t\t\tlistener(changes)\n\t\t}\n\t}\n\n\tlocalChannel.addEventListener('message', (e: CustomEventInit<DatabaseChangeDetails[]>) => {\n\t\tconst changes = e.detail\n\n\t\tif (!changes) {\n\t\t\treturn\n\t\t}\n\n\t\tnotifyListeners(changes)\n\t})\n\n\tcrossChannel.addEventListener('message', (e: MessageEvent<DatabaseChangeDetails[]>) => {\n\t\tnotifyListeners(e.data)\n\t})\n}\n\nexport const onDatabaseChange = (\n\thandler: (changes: readonly DatabaseChangeDetails[]) => void,\n): (() => void) => {\n\tif (import.meta.env.SSR) {\n\t\treturn () => {}\n\t}\n\n\tlisteners.add(handler)\n\n\treturn () => {\n\t\tlisteners.delete(handler)\n\t}\n}\n\nexport const dispatchDatabaseChangedEvent = (\n\tchanges: readonly (DatabaseChangeDetails | undefined)[] | DatabaseChangeDetails,\n): void => {\n\tconst changesArray = Array.isArray(changes) ? changes : [changes]\n\n\tconst filteredChanges = changesArray.filter((c) => c !== undefined)\n\tif (filteredChanges.length === 0) {\n\t\treturn\n\t}\n\n\tlocalChannel.dispatchEvent(new CustomEvent('message', { detail: filteredChanges }))\n\tcrossChannel.postMessage(filteredChanges)\n}\n"
  },
  {
    "path": "src/lib/db/lock-database.ts",
    "content": "import { SvelteSet } from 'svelte/reactivity'\n\nlet counter = 0\nconst pendingTasks = new SvelteSet<number>()\n/**\n * Returns reactive boolean value stating if database operation is pending.\n */\nexport const isDatabaseOperationPending = (): boolean => pendingTasks.size > 0\n\n/**\n * Prevents other tasks using this function from running while one is pending.\n * Works between different tabs/threads using the\n * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API Web Locks API }\n *\n * Generally, should only be used for long-running database mutation operations.\n */\nexport const lockDatabase = async <T = void>(action: () => Promise<T>): Promise<T> => {\n\tconst id = counter\n\tcounter += 1\n\tpendingTasks.add(id)\n\n\ttry {\n\t\treturn await navigator.locks.request('database', () => action())\n\t} finally {\n\t\tpendingTasks.delete(id)\n\t}\n}\n"
  },
  {
    "path": "src/lib/db/query/base-query.svelte.ts",
    "content": "import { assign } from '$lib/helpers/utils/assign.ts'\nimport { type DatabaseChangeDetailsList, onDatabaseChange } from '../events.ts'\n\nexport type QueryStatus = 'loading' | 'loaded' | 'error'\n\ninterface QueryBaseResult {\n\tstatus: QueryStatus\n}\n\ninterface QueryLoadedResult<Result> {\n\tstatus: 'loaded'\n\tloading: false\n\tvalue: Result\n\terror: undefined\n}\n\ninterface QueryLoadingResult<Result> {\n\tstatus: 'loading'\n\tloading: true\n\tvalue: Result | undefined\n\terror: undefined\n}\n\ninterface QueryErrorResult<Result> {\n\tstatus: 'error'\n\tloading: false\n\tvalue: Result | undefined\n\terror: unknown\n}\n\nexport type QueryResult<Result> = QueryBaseResult &\n\t(QueryLoadedResult<Result> | QueryLoadingResult<Result> | QueryErrorResult<Result>)\n\nexport type QueryMutate<Result> = (value: Result | ((prev: Result | undefined) => Result)) => void\n\nexport interface DbChangeActions<Result> {\n\tmutate: QueryMutate<Result>\n\trefetch: () => void\n}\n\nexport type DatabaseChangeHandler<Result> = (\n\tchanges: DatabaseChangeDetailsList,\n\tactions: DbChangeActions<Result>,\n) => void\n\nexport type QueryKeyPrimitiveValue = number | string | boolean\nexport type QueryKey = QueryKeyPrimitiveValue | QueryKeyPrimitiveValue[]\n\nconst normalizeKey = <const K extends QueryKey>(key: K): QueryKeyPrimitiveValue =>\n\tArray.isArray(key) ? key.join(',') : key\n\nexport interface QueryBaseOptions<K extends QueryKey, Result> {\n\tkey: K | (() => K)\n\tfetcher: (key: K, signal: AbortSignal) => Promise<Result> | Result\n\tonDatabaseChange?: DatabaseChangeHandler<Result>\n}\n\nexport type QueryStateInternal<Result> = Omit<QueryResult<Result>, 'loading'>\n\nexport class QueryImpl<K extends QueryKey, Result> {\n\tstate: QueryStateInternal<Result> = $state({\n\t\tstatus: 'loading',\n\t\terror: undefined,\n\t\tvalue: undefined,\n\t})\n\n\tresolvedKey: QueryKeyPrimitiveValue | undefined = undefined\n\n\t#abortController: AbortController | undefined = undefined\n\n\toptions: QueryBaseOptions<K, Result>\n\n\tconstructor(options: QueryBaseOptions<K, Result>) {\n\t\tthis.options = options\n\t}\n\n\t#getKey() {\n\t\tconst key = this.options.key\n\n\t\treturn typeof key === 'function' ? key() : key\n\t}\n\n\t#setErrorState = (e: unknown, normalizedKey: QueryKeyPrimitiveValue) => {\n\t\tthis.resolvedKey = normalizedKey\n\t\tassign(this.state, {\n\t\t\tstatus: 'error',\n\t\t\tvalue: undefined,\n\t\t\terror: e,\n\t\t})\n\t}\n\n\t#setLoadedState = (value: Result, normalizedKey: QueryKeyPrimitiveValue) => {\n\t\tthis.resolvedKey = normalizedKey\n\t\tassign(this.state, {\n\t\t\tstatus: 'loaded',\n\t\t\tvalue,\n\t\t\terror: undefined,\n\t\t})\n\t}\n\n\t#loadWithKey = async (key: K, normalizedKey: QueryKeyPrimitiveValue) => {\n\t\tthis.#abortController?.abort()\n\t\tconst controller = new AbortController()\n\t\tthis.#abortController = controller\n\n\t\ttry {\n\t\t\tconst result = this.options.fetcher(key, controller.signal)\n\n\t\t\tif (result instanceof Promise) {\n\t\t\t\t// We only need to set loading state if it is async\n\t\t\t\tassign(this.state, {\n\t\t\t\t\tstatus: 'loading',\n\t\t\t\t\terror: undefined,\n\t\t\t\t})\n\n\t\t\t\tconst resultValue = await result\n\t\t\t\tthis.#setLoadedState(resultValue, normalizedKey)\n\t\t\t} else {\n\t\t\t\tthis.#setLoadedState(result, normalizedKey)\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tthis.#setErrorState(e, normalizedKey)\n\t\t}\n\t}\n\n\tload = (): Promise<void> => {\n\t\tconst key = this.#getKey()\n\t\tconst normalizedKey = normalizeKey(key)\n\n\t\treturn this.#loadWithKey(key, normalizedKey)\n\t}\n\n\tsetupListeners = (): void => {\n\t\t$effect(() => {\n\t\t\tconst key = this.#getKey()\n\t\t\tconst normalizedKey = normalizeKey(key)\n\n\t\t\tuntrack(() => {\n\t\t\t\tif (this.resolvedKey === normalizedKey) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tvoid this.#loadWithKey(key, normalizedKey)\n\t\t\t})\n\t\t})\n\n\t\t$effect(() => {\n\t\t\tconst stopListening = untrack(() =>\n\t\t\t\tonDatabaseChange((changes) => {\n\t\t\t\t\tthis.options.onDatabaseChange?.(changes, {\n\t\t\t\t\t\tmutate: (v) => {\n\t\t\t\t\t\t\tlet value: Result | undefined\n\t\t\t\t\t\t\tif (typeof v === 'function') {\n\t\t\t\t\t\t\t\tconst accessor = v as (prev: Result | undefined) => Result\n\t\t\t\t\t\t\t\tvalue = accessor(this.state.value)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tthis.#setLoadedState(value as Result, normalizeKey(this.#getKey()))\n\t\t\t\t\t\t},\n\t\t\t\t\t\trefetch: async () => {\n\t\t\t\t\t\t\tawait this.load()\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}),\n\t\t\t)\n\t\t\treturn () => {\n\t\t\t\tstopListening()\n\t\t\t}\n\t\t})\n\t}\n}\n\nexport class QueryResultBox<Result> {\n\t#state: QueryStateInternal<Result>\n\n\tconstructor(state: QueryStateInternal<Result>) {\n\t\tthis.#state = state\n\t}\n\n\tget value() {\n\t\treturn this.#state.value\n\t}\n\n\tget error() {\n\t\treturn this.#state.error\n\t}\n\n\tget status() {\n\t\treturn this.#state.status\n\t}\n\n\tget loading() {\n\t\treturn this.#state.status === 'loading'\n\t}\n}\n"
  },
  {
    "path": "src/lib/db/query/inline-query.svelte.ts",
    "content": "import { type DatabaseChangeDetailsList, onDatabaseChange } from '../events.ts'\nimport type { QueryKey } from './base-query.svelte.ts'\n\nexport type { QueryKey, QueryResult } from './base-query.svelte.ts'\n\nexport interface InlineQueryOptions<K extends QueryKey, Result> {\n\tkey: K | (() => K)\n\tfetcher: (key: K) => Promise<Result> | Result\n\tonDatabaseChange?: (changes: DatabaseChangeDetailsList) => boolean\n}\n\nexport const createInlineQuery = <const K extends QueryKey, Result>(\n\toptions: InlineQueryOptions<K, Result>,\n) => {\n\tlet counter = $state(0)\n\n\tconst load = () => {\n\t\tvoid counter\n\t\tconst key = typeof options.key === 'function' ? options.key() : options.key\n\n\t\treturn untrack(() => options.fetcher(key))\n\t}\n\n\t$effect(() => {\n\t\tconst stopListening = untrack(() =>\n\t\t\tonDatabaseChange((changes) => {\n\t\t\t\tconst changed = options.onDatabaseChange?.(changes)\n\t\t\t\tif (changed) {\n\t\t\t\t\tcounter += 1\n\t\t\t\t}\n\t\t\t}),\n\t\t)\n\t\treturn () => {\n\t\t\tstopListening()\n\t\t}\n\t})\n\n\treturn load\n}\n"
  },
  {
    "path": "src/lib/db/query/page-query.svelte.ts",
    "content": "import {\n\ttype QueryBaseOptions,\n\tQueryImpl,\n\ttype QueryKey,\n\ttype QueryResult,\n\tQueryResultBox,\n\ttype QueryStateInternal,\n} from './base-query.svelte.ts'\n\nexport type { QueryKey } from './base-query.svelte.ts'\n\nexport type PageQueryResult<Result> = QueryResult<Result> & {\n\tvalue: Result\n}\nexport type PageQueryOptions<K extends QueryKey, Result> = QueryBaseOptions<K, Result>\n\nconst pageQueryHydrateSymbol: unique symbol = Symbol()\n\nclass PageQueryResultBox<Result> extends QueryResultBox<Result> {\n\t#setupListeners: () => void\n\n\t#hydrated = false\n\n\tconstructor(state: QueryStateInternal<Result>, setupListeners: () => void) {\n\t\tsuper(state)\n\t\tthis.#setupListeners = setupListeners\n\t}\n\n\t[pageQueryHydrateSymbol](): void {\n\t\tif (this.#hydrated) {\n\t\t\treturn\n\t\t}\n\n\t\tthis.#hydrated = true\n\t\tthis.#setupListeners()\n\t}\n}\n\n/**\n * Create a page query which should load data inside load function\n * and then listen for database changes once page is loaded.\n * @public\n */\nexport const createPageQuery = async <const K extends QueryKey, Result>(\n\toptions: PageQueryOptions<K, Result>,\n): Promise<PageQueryResult<Result>> => {\n\tconst query = new QueryImpl<K, Result>(options)\n\tconst { state } = query\n\n\tawait query.load()\n\n\tif (state.error) {\n\t\tthrow state.error\n\t}\n\n\tconst result = new PageQueryResultBox<Result>(\n\t\tstate,\n\t\tquery.setupListeners,\n\t) as PageQueryResult<Result>\n\n\treturn result\n}\n\n/**\n * Initialize pages queries once page is loaded.\n * Should be called inside page component.\n * @public\n */\nexport const initPageQueries = (data: () => Record<string, unknown>): void => {\n\t$effect.pre(() => {\n\t\tfor (const query of Object.values(data())) {\n\t\t\tif (query instanceof PageQueryResultBox) {\n\t\t\t\tquery[pageQueryHydrateSymbol]()\n\t\t\t}\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "src/lib/db/query/query.ts",
    "content": "import {\n\tQueryImpl,\n\ttype QueryKey,\n\ttype QueryBaseOptions as QueryOptions,\n\ttype QueryResult,\n\tQueryResultBox,\n} from './base-query.svelte.ts'\n\nexport type { QueryKey, QueryResult } from './base-query.svelte.ts'\nexport type { QueryOptions }\n\nexport const createQuery = <const K extends QueryKey, Result>(options: QueryOptions<K, Result>) => {\n\tconst query = new QueryImpl<K, Result>(options)\n\tquery.setupListeners()\n\n\tvoid query.load()\n\n\treturn new QueryResultBox(query.state) as QueryResult<Result>\n}\n"
  },
  {
    "path": "src/lib/helpers/__tests__/serial-queue.test.ts",
    "content": "/** biome-ignore-all lint/suspicious/useAwait: test code */\nimport { describe, expect, it, vi } from 'vitest'\nimport { SerialQueue } from '../serial-queue.ts'\nimport { wait } from '../utils/wait.ts'\n\ndescribe('SerialQueue', () => {\n\tit('executes a single task', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tconst fn = vi.fn().mockResolvedValue(undefined)\n\n\t\tawait queue.enqueue(fn)\n\n\t\texpect(fn).toHaveBeenCalledOnce()\n\t})\n\n\tit('executes tasks in order', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tconst order: number[] = []\n\n\t\tvoid queue.enqueue(async () => {\n\t\t\torder.push(1)\n\t\t})\n\t\tvoid queue.enqueue(async () => {\n\t\t\torder.push(2)\n\t\t})\n\t\tvoid queue.enqueue(async () => {\n\t\t\torder.push(3)\n\t\t})\n\n\t\tawait queue.drain()\n\n\t\texpect(order).toEqual([1, 2, 3])\n\t})\n\n\tit('waits for the previous task to finish before starting the next', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tlet running = 0\n\t\tlet maxConcurrent = 0\n\n\t\tconst makeTask = () => async () => {\n\t\t\trunning += 1\n\t\t\tmaxConcurrent = Math.max(maxConcurrent, running)\n\t\t\tawait wait(10)\n\t\t\trunning -= 1\n\t\t}\n\n\t\tvoid queue.enqueue(makeTask())\n\t\tvoid queue.enqueue(makeTask())\n\t\tvoid queue.enqueue(makeTask())\n\n\t\tawait queue.drain()\n\n\t\texpect(maxConcurrent).toBe(1)\n\t})\n\n\tit('drain() resolves after all enqueued tasks complete', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tconst completed: number[] = []\n\n\t\tvoid queue.enqueue(async () => {\n\t\t\tawait new Promise<void>((r) => setTimeout(r, 20))\n\t\t\tcompleted.push(1)\n\t\t})\n\t\tvoid queue.enqueue(async () => {\n\t\t\tcompleted.push(2)\n\t\t})\n\n\t\tawait queue.drain()\n\n\t\texpect(completed).toEqual([1, 2])\n\t})\n\n\tit('drain() resolves immediately when queue is empty', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tawait expect(queue.drain()).resolves.toBeUndefined()\n\t})\n\n\tit('continues processing subsequent tasks after a task throws', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tconst order: string[] = []\n\n\t\tconst failing = queue.enqueue(async () => {\n\t\t\torder.push('failing')\n\t\t\tthrow new Error('oops')\n\t\t})\n\n\t\tvoid queue.enqueue(async () => {\n\t\t\torder.push('after-failure')\n\t\t})\n\n\t\tawait expect(failing).rejects.toThrow('oops')\n\t\tawait queue.drain()\n\n\t\texpect(order).toEqual(['failing', 'after-failure'])\n\t})\n\n\tit('enqueue() returns the promise from the task function', async () => {\n\t\tconst queue = new SerialQueue()\n\t\tconst result = queue.enqueue(async () => {\n\t\t\tawait Promise.resolve()\n\t\t})\n\n\t\tawait expect(result).resolves.toBeUndefined()\n\t})\n\n\tit('enqueue() propagates task rejection to the caller', async () => {\n\t\tconst queue = new SerialQueue()\n\n\t\tconst result = queue.enqueue(async () => {\n\t\t\tthrow new Error('task error')\n\t\t})\n\n\t\tawait expect(result).rejects.toThrow('task error')\n\t})\n})\n"
  },
  {
    "path": "src/lib/helpers/animations.ts",
    "content": "/** @public */\nexport const animateEmpty = (\n\telement: Element,\n\toptions: number | KeyframeAnimationOptions,\n): Animation => element.animate(null, options)\n\nexport interface SequenceKeyframeAnimationOptions extends KeyframeAnimationOptions {\n\t/** '<' means start at the same time as previous animation */\n\tat?: '<'\n}\n\nexport type AnimationSequence = [\n\tElement,\n\tKeyframe[] | PropertyIndexedKeyframes,\n\tSequenceKeyframeAnimationOptions?,\n]\n\nexport interface AnimationSequenceOptions {\n\tdefaultOptions?: KeyframeAnimationOptions\n}\n\nexport const timeline = async (\n\tsequence: readonly AnimationSequence[],\n\tsequenceOptions: AnimationSequenceOptions = {},\n): Promise<Animation[]> => {\n\tconst animations: readonly [Animation, runWithPrevious: boolean][] = sequence.map(\n\t\t([element, keyframes, options]) => {\n\t\t\tconst animation = element.animate(keyframes, {\n\t\t\t\t...sequenceOptions.defaultOptions,\n\t\t\t\t...options,\n\t\t\t})\n\t\t\tanimation.pause()\n\n\t\t\treturn [animation, options?.at === '<']\n\t\t},\n\t)\n\n\tconst promises: Promise<Animation>[] = []\n\tfor (const [animation, runWithPrevious] of animations) {\n\t\tif (!runWithPrevious) {\n\t\t\tawait promises.at(-1)\n\t\t}\n\n\t\tanimation.play()\n\t\tpromises.push(animation.finished)\n\t}\n\n\treturn Promise.all(promises)\n}\n"
  },
  {
    "path": "src/lib/helpers/audio.ts",
    "content": "import { isMobile, isSafari } from './utils/ua.ts'\n\n/**\n * Safari mobile does not allow changing audio volume\n * @public\n */\nexport const supportsChangingAudioVolume = () => {\n\tif (isMobile() && isSafari()) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "src/lib/helpers/create-managed-artwork.svelte.ts",
    "content": "class Artwork {\n\tstatic idCounter = 0\n\n\tstatic createRefId() {\n\t\tconst index = Artwork.idCounter\n\t\tArtwork.idCounter += 1\n\n\t\treturn index\n\t}\n\n\timage: Blob\n\n\turl: string\n\n\trefs = new Set<number>()\n\n\tconstructor(image: Blob) {\n\t\tthis.image = image\n\t\tthis.url = URL.createObjectURL(image)\n\t}\n}\n\nconst cache = new WeakMap<Blob, Artwork>()\nconst cleanupQueue = new Set<Blob>()\nlet isCleanupScheduled = false\nconst scheduleCleanup = (artwork: Artwork) => {\n\tcleanupQueue.add(artwork.image)\n\n\tif (isCleanupScheduled) {\n\t\treturn\n\t}\n\n\tisCleanupScheduled = true\n\tconst thirtySeconds = 30 * 1000\n\tsetTimeout(() => {\n\t\tfor (const blob of cleanupQueue) {\n\t\t\tconst cached = cache.get(blob)\n\t\t\tif (!cached) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (cached.refs.size === 0) {\n\t\t\t\tcache.delete(blob)\n\t\t\t\tURL.revokeObjectURL(cached.url)\n\t\t\t}\n\t\t}\n\t\tcleanupQueue.clear()\n\t\tisCleanupScheduled = false\n\t}, thirtySeconds)\n}\n\nexport const createManagedArtwork = (getImage: () => Blob | undefined | null) => {\n\tconst refId = Artwork.createRefId()\n\n\tconst artwork = $derived.by(() => {\n\t\tconst image = getImage()\n\n\t\tif (!image) {\n\t\t\treturn null\n\t\t}\n\n\t\tlet artworkInstance = cache.get(image)\n\t\tif (!artworkInstance) {\n\t\t\tartworkInstance = new Artwork(image)\n\n\t\t\tcache.set(image, artworkInstance)\n\t\t}\n\n\t\tartworkInstance.refs.add(refId)\n\n\t\treturn artworkInstance\n\t})\n\n\t$effect(() => {\n\t\t// Need to use variable here so cleanup uses\n\t\t// previous value instead of the current one\n\t\tconst savedArtwork = artwork\n\t\tif (!savedArtwork) {\n\t\t\treturn\n\t\t}\n\n\t\treturn () => {\n\t\t\tif (savedArtwork.refs.size === 1) {\n\t\t\t\tscheduleCleanup(savedArtwork)\n\t\t\t}\n\n\t\t\tif (import.meta.env.DEV && !savedArtwork.refs.has(refId)) {\n\t\t\t\tconsole.warn('Trying to release artwork that is not in use', savedArtwork)\n\t\t\t}\n\n\t\t\tsavedArtwork.refs.delete(refId)\n\t\t}\n\t})\n\n\treturn () => artwork?.url\n}\n"
  },
  {
    "path": "src/lib/helpers/debounced.svelte.ts",
    "content": "import { debounce } from './utils/debounce.ts'\n\ntype Getter<T> = () => T\n\nexport class Debounced<T> {\n\t#current: T = $state() as T\n\n\tget current(): T {\n\t\treturn this.#current\n\t}\n\n\tconstructor(getter: Getter<T>, delay: number) {\n\t\tthis.#current = getter()\n\n\t\tconst debouncedFn = debounce((v: T) => {\n\t\t\tthis.#current = v\n\t\t}, delay)\n\n\t\t$effect(() => {\n\t\t\tconst value = getter()\n\t\t\tdebouncedFn(value)\n\n\t\t\treturn () => {\n\t\t\t\tdebouncedFn.cancel()\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "src/lib/helpers/file-system.ts",
    "content": "import { isMobile } from '$lib/helpers/utils/ua.ts'\n\nexport const isFileSystemAccessSupported: boolean = 'showDirectoryPicker' in globalThis\n\nexport type FileEntity = File | FileSystemFileHandle\n\nconst supportedExtensions = ['aac', 'mp3', 'ogg', 'wav', 'flac', 'm4a', 'opus', 'webm']\nconst supportedExtensionsWithDot = supportedExtensions.map((ext) => `.${ext}`)\n\nconst isSupportedFile = (fileName: string): boolean => {\n\t// On Windows .MP3 and .mp3 are both valid file extensions\n\tconst fileNameLower = fileName.toLowerCase()\n\n\treturn supportedExtensionsWithDot.some((ext) => fileNameLower.endsWith(ext))\n}\n\nexport const getFileHandlesRecursively = async (\n\tdirectory: FileSystemDirectoryHandle,\n): Promise<FileSystemFileHandle[]> => {\n\tconst files: FileSystemFileHandle[] = []\n\n\tfor await (const handle of directory.values()) {\n\t\tif (handle.kind === 'file') {\n\t\t\tconst isValidFile = isSupportedFile(handle.name)\n\n\t\t\tif (isValidFile) {\n\t\t\t\tfiles.push(handle)\n\t\t\t}\n\t\t} else if (handle.kind === 'directory') {\n\t\t\tconst additionalFiles = await getFileHandlesRecursively(handle)\n\n\t\t\tfiles.push(...additionalFiles)\n\t\t}\n\t}\n\treturn files\n}\n\nconst getFilesFromLegacyInputEvent = (e: Event): File[] => {\n\tconst { files } = e.target as HTMLInputElement\n\tif (!files) {\n\t\treturn []\n\t}\n\n\treturn Array.from(files).filter((file) => isSupportedFile(file.name))\n}\n\nexport const getFilesFromLegacyDirectory = (): Promise<File[]> => {\n\tconst directoryElement = document.createElement('input')\n\tdirectoryElement.type = 'file'\n\n\t// Mobile devices do not support directory selection,\n\t// so allow them to pick individual files instead.\n\tif (isMobile()) {\n\t\tdirectoryElement.accept = supportedExtensionsWithDot.join(', ')\n\n\t\tdirectoryElement.multiple = true\n\t} else {\n\t\tdirectoryElement.setAttribute('webkitdirectory', '')\n\t\tdirectoryElement.setAttribute('directory', '')\n\t}\n\n\tconst { promise, resolve: resolvePromise } = Promise.withResolvers<File[]>()\n\n\tconst resolve = (files: File[]) => {\n\t\tdirectoryElement.remove()\n\t\tresolvePromise(files)\n\t}\n\n\tdirectoryElement.addEventListener('change', (e) => {\n\t\tresolve(getFilesFromLegacyInputEvent(e))\n\t})\n\n\tdirectoryElement.addEventListener('cancel', () => {\n\t\tresolve([])\n\t})\n\n\tdirectoryElement.addEventListener('error', () => {\n\t\tresolve([])\n\t})\n\n\t// See https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only\n\tdirectoryElement.style.position = 'fixed'\n\tdirectoryElement.style.top = '-100000px'\n\tdirectoryElement.style.left = '-100000px'\n\tdocument.body.appendChild(directoryElement)\n\n\tdirectoryElement.click()\n\n\treturn promise\n}\n"
  },
  {
    "path": "src/lib/helpers/focus.ts",
    "content": "export const doesElementHasFocus = (element: Element): boolean => element.matches(':focus')\n\nexport const findFocusedElement = (container: Element | Document): HTMLElement | null => {\n\tconst element = container.querySelector(':focus')\n\n\t// If element contains focus it must be instanceof HTMLElement,\n\t// otherwise it's always null\n\treturn element as HTMLElement | null\n}\n"
  },
  {
    "path": "src/lib/helpers/input.ts",
    "content": "const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'password', 'number'])\n\n/**\n * Checks if the given element is a text input or textarea.\n */\nexport const isElementTextInput = (element: Element | EventTarget | undefined | null) => {\n\tif (\n\t\t(element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type)) ||\n\t\telement instanceof HTMLTextAreaElement\n\t) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "src/lib/helpers/persist.svelte.ts",
    "content": "const getValue = (key: string) => {\n\ttry {\n\t\tconst valueRaw = localStorage.getItem(key)\n\t\tconst value = valueRaw === null || valueRaw === undefined ? null : JSON.parse(valueRaw)\n\n\t\treturn value\n\t} catch (error) {\n\t\tconsole.error(`Failed to get persisted value for key \"${key}\"`, error)\n\n\t\treturn null\n\t}\n}\n\nconst getStorageKey = (storeName: string, key: string) => `snaeplayer-${storeName}.${key}`\n\nexport const getPersistedValue = <T, D = null>(\n\tstoreName: string,\n\tkey: string,\n\tdefaultValue: D = null as D,\n): T | D => {\n\tconst fullKey = getStorageKey(storeName, key)\n\tconst value = getValue(fullKey)\n\n\treturn value ?? defaultValue\n}\n\nexport const persist = <T>(storeName: string, instance: T, keys: (keyof T & string)[]): void => {\n\t$effect.root(() => {\n\t\tfor (const key of keys) {\n\t\t\tconst storageKey = getStorageKey(storeName, key)\n\n\t\t\tconst value = getValue(storageKey)\n\t\t\tif (value !== null) {\n\t\t\t\tinstance[key] = value\n\t\t\t}\n\n\t\t\tlet initial = true\n\t\t\t$effect(() => {\n\t\t\t\tconst updatedValue = instance[key]\n\n\t\t\t\tif (initial) {\n\t\t\t\t\tinitial = false\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tlocalStorage.setItem(storageKey, JSON.stringify(updatedValue))\n\t\t\t})\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "src/lib/helpers/register-sw.ts",
    "content": "// https://whatwebcando.today/articles/handling-service-worker-updates/\n\nconst waitForPageToLoad = () => {\n\tconst { promise, resolve } = Promise.withResolvers<void>()\n\n\tif (document.readyState === 'loading') {\n\t\twindow.addEventListener('load', () => resolve(), { once: true })\n\t} else {\n\t\tresolve()\n\t}\n\n\treturn promise\n}\n\n/** @public */\nexport interface RegisterSwOptions {\n\tonNeedRefresh: (updateSw: () => void) => void\n}\n\n/** @public */\nexport const registerServiceWorker = async (options: RegisterSwOptions) => {\n\tif (import.meta.env.DEV) {\n\t\treturn\n\t}\n\n\tawait waitForPageToLoad()\n\n\tconst { serviceWorker } = navigator\n\tconst registration = await serviceWorker.register('/service-worker.js', {\n\t\tscope: '/',\n\t})\n\n\tconst needsRefresh = (reg: ServiceWorkerRegistration) => {\n\t\tconst updateSw = () => {\n\t\t\tconst { waiting } = reg\n\t\t\tif (waiting) {\n\t\t\t\twaiting.postMessage('skip-waiting')\n\t\t\t}\n\t\t}\n\n\t\toptions.onNeedRefresh(updateSw)\n\t}\n\n\t// ensure the case when the updatefound event was missed is also handled\n\t// by re-invoking the prompt when there's a waiting Service Worker\n\tif (registration.waiting) {\n\t\tneedsRefresh(registration)\n\t}\n\n\tlet firstLoad = false\n\n\tregistration.addEventListener('updatefound', () => {\n\t\tconst { installing } = registration\n\t\tif (!installing) {\n\t\t\treturn\n\t\t}\n\n\t\t// wait until the new Service worker is actually installed (ready to take over)\n\t\tinstalling.addEventListener('statechange', () => {\n\t\t\tif (registration.waiting) {\n\t\t\t\tif (navigator.serviceWorker.controller) {\n\t\t\t\t\t// if there's an existing controller (previous Service Worker), show the prompt\n\t\t\t\t\tneedsRefresh(registration)\n\t\t\t\t} else {\n\t\t\t\t\tfirstLoad = true\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\tlet refreshing = false\n\t// detect controller change and refresh the page\n\tnavigator.serviceWorker.addEventListener('controllerchange', () => {\n\t\tif (firstLoad) {\n\t\t\tfirstLoad = false\n\t\t\treturn\n\t\t}\n\n\t\tif (!refreshing) {\n\t\t\twindow.location.reload()\n\t\t\trefreshing = true\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "src/lib/helpers/serial-queue.ts",
    "content": "/** @public */\nexport class SerialQueue {\n\t#chain = Promise.resolve()\n\n\tenqueue(promiseFn: () => Promise<void>): Promise<void> {\n\t\tconst result = this.#chain.then(promiseFn)\n\t\tthis.#chain = result.catch(() => {})\n\n\t\treturn result\n\t}\n\n\tdrain(): Promise<void> {\n\t\treturn this.#chain\n\t}\n}\n"
  },
  {
    "path": "src/lib/helpers/test-helpers.ts",
    "content": "import { expect } from 'vitest'\nimport { type AppStoreNames, getDatabase } from '$lib/db/database.ts'\n\n/** @public */\nexport const clearDatabaseStores = async () => {\n\tconst db = await getDatabase()\n\tfor (const storeName of db.objectStoreNames) {\n\t\tawait db.clear(storeName)\n\t}\n}\n\n/** @public */\nexport function expectToBeDefined<T>(value: T | undefined): asserts value is T {\n\texpect(value).toBeDefined()\n}\n\n/** @public */\nexport const dbGetAllAndExpectLength = async <S extends AppStoreNames>(\n\tstoreName: S,\n\texpectedCount: number,\n\tmessage?: string,\n) => {\n\tconst db = await getDatabase()\n\tconst items = await db.getAll(storeName)\n\texpect(items, message).toHaveLength(expectedCount)\n\n\treturn items\n}\n"
  },
  {
    "path": "src/lib/helpers/ui-action.ts",
    "content": "/**\n * Executes a UI action that shows a success message upon completion or an error message if the action fails.\n */\nexport const createUIAction = <P extends unknown[] = []>(\n\tsuccessMessage: string | false,\n\taction: (...params: P) => Promise<void>,\n) => {\n\tconst wrappedAction = async (...params: P): Promise<void> => {\n\t\ttry {\n\t\t\tawait action(...params)\n\t\t\tif (successMessage) {\n\t\t\t\tsnackbar(successMessage)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error executing UI action:', error)\n\t\t\tsnackbar.unexpectedError(error)\n\t\t}\n\t}\n\n\treturn wrappedAction\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/array.ts",
    "content": "/** @public */\nexport const toShuffledArray = <T>(input: T[]): T[] => {\n\tconst output = [...input]\n\tfor (let i = output.length - 1; i > 0; i -= 1) {\n\t\tconst j = Math.floor(Math.random() * (i + 1))\n\t\tconst temp = output[i] as T\n\n\t\toutput[i] = output[j] as T\n\t\toutput[j] = temp\n\t}\n\n\treturn output\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/assign.ts",
    "content": "// biome-ignore lint/suspicious/noExplicitAny: needed for inference\ntype Impossible<K extends keyof any> = {\n\t[P in K]: never\n}\n\nexport const assign = <T extends {}, S extends Partial<T>>(\n\ttarget: T,\n\tsource: S & Impossible<Exclude<keyof S, keyof T>>,\n): S & T => Object.assign(target, source)\n"
  },
  {
    "path": "src/lib/helpers/utils/clamp.ts",
    "content": "export const clamp = (num: number, min: number, max: number): number =>\n\tMath.min(Math.max(num, min), max)\n"
  },
  {
    "path": "src/lib/helpers/utils/debounce.ts",
    "content": "/** @public */\nexport const debounce = <Fn extends (...args: Parameters<Fn>) => ReturnType<Fn>>(\n\tfn: Fn,\n\tdelay: number,\n): {\n\t(...args: Parameters<Fn>): void\n\tcancel: () => void\n} => {\n\tlet timeout: undefined | number\n\n\tconst debounceFn = (...args: Parameters<Fn>) => {\n\t\tclearTimeout(timeout)\n\n\t\ttimeout = setTimeout(fn, delay, ...(args as unknown[]))\n\t}\n\n\tdebounceFn.cancel = () => {\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout)\n\t\t\ttimeout = undefined\n\t\t}\n\t}\n\n\treturn debounceFn\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/format-duration.ts",
    "content": "const twoDigits = (num: number) => num.toString().padStart(2, '0')\n\nexport const formatDuration = (seconds: number) => {\n\tif (!Number.isFinite(seconds)) {\n\t\treturn '--:--'\n\t}\n\n\tconst hours = Math.floor(seconds / 3600)\n\tconst minutes = Math.floor((seconds % 3600) / 60)\n\tconst secs = Math.floor(seconds % 60)\n\n\treturn `${hours ? `${hours}:` : ''}${twoDigits(minutes)}:${twoDigits(secs)}`\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/integers.ts",
    "content": "export const safeInteger = (num: number, fallback = 0): number => {\n\tif (Number.isSafeInteger(num)) {\n\t\treturn num\n\t}\n\n\treturn fallback\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/navigate.ts",
    "content": "export const navigateToExternal = (url: string) => {\n\twindow.open(url, '_blank', 'noopener,noreferrer')\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/text.ts",
    "content": "import { type StringOrUnknownItem, UNKNOWN_ITEM } from '$lib/library/types.ts'\n\nexport const truncate = (text: string, length: number): string => {\n\tif (text.length <= length) {\n\t\treturn text\n\t}\n\n\treturn `${text.slice(0, length)}...`\n}\n\nexport const formatArtists = (artists: readonly StringOrUnknownItem[]): string =>\n\tartists.filter((artist) => artist !== UNKNOWN_ITEM).join(', ')\n\nexport const formatNameOrUnknown = (name: StringOrUnknownItem, fallback = m.unknown()): string =>\n\tname === UNKNOWN_ITEM ? fallback : name\n\nexport const getItemLanguage = (language: string | undefined): string | undefined => {\n\tif (!language) {\n\t\treturn\n\t}\n\n\tconst lang = language.toLowerCase()\n\tswitch (lang) {\n\t\tcase 'jp':\n\t\tcase 'jap':\n\t\tcase 'japanese':\n\t\t\treturn 'ja'\n\n\t\tcase 'korean':\n\t\t\treturn 'ko'\n\n\t\tcase 'zh-cn':\n\t\t\treturn 'zh-CN'\n\n\t\tcase 'zh-tw':\n\t\t\treturn 'zh-TW'\n\n\t\tcase 'zho':\n\t\tcase 'chinese':\n\t\tcase 'zh':\n\t\t\treturn 'zh-CN'\n\n\t\tcase 'cantonese':\n\t\t\treturn 'yue'\n\n\t\tcase 'fre':\n\t\tcase 'french':\n\t\t\treturn 'fr'\n\n\t\tcase 'esp':\n\t\tcase 'spanish':\n\t\t\treturn 'es'\n\n\t\tcase 'eng':\n\t\tcase 'english':\n\t\t\treturn 'en'\n\n\t\tdefault:\n\t\t\treturn lang\n\t}\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/throttle.ts",
    "content": "export const throttle = <Fn extends (...args: Parameters<Fn>) => ReturnType<Fn>>(\n\tfn: Fn,\n\tdelay: number,\n): {\n\t(...args: Parameters<Fn>): ReturnType<Fn>\n\tcancel: () => void\n} => {\n\tlet wait = false\n\tlet timeout: undefined | number\n\tlet prevValue: ReturnType<Fn> | undefined\n\n\tconst throttleFn = (...args: Parameters<Fn>) => {\n\t\tif (wait) {\n\t\t\t// prevValue always defined by the\n\t\t\t// time wait is true\n\t\t\treturn prevValue as ReturnType<Fn>\n\t\t}\n\n\t\tconst val = fn(...args)\n\t\tprevValue = val\n\n\t\twait = true\n\n\t\ttimeout = window.setTimeout(() => {\n\t\t\twait = false\n\t\t}, delay)\n\n\t\treturn val\n\t}\n\tthrottleFn.cancel = () => {\n\t\tclearTimeout(timeout)\n\t}\n\treturn throttleFn\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/ua.ts",
    "content": "const isMobileRegex = /Android|iPhone|iPad|iPod/i\nconst isMacRegex = /Macintosh|Mac OS X/i\nconst isWindowsRegex = /Windows/i\nconst isAndroidRegex = /Android/i\n\nconst runOnce = <T>(fn: () => T): (() => T) => {\n\tlet result: T\n\tlet hasRun = false\n\n\treturn () => {\n\t\tif (hasRun) {\n\t\t\treturn result\n\t\t}\n\n\t\tresult = fn()\n\t\thasRun = true\n\n\t\treturn result\n\t}\n}\n\n/** @public */\nexport const isMobile = runOnce((): boolean => {\n\tif (navigator.userAgentData) {\n\t\treturn navigator.userAgentData.mobile\n\t}\n\n\treturn isMobileRegex.test(navigator.userAgent)\n})\n\n/** @public */\nexport const isSafari = runOnce(() => {\n\tconst ua = navigator.userAgent.toLowerCase()\n\n\treturn ua.includes('applewebkit') && !ua.includes('chrome') && !ua.includes('chromium')\n})\n\n/** @public */\nexport const isMac = runOnce((): boolean => {\n\tif (navigator.userAgentData?.platform) {\n\t\treturn navigator.userAgentData.platform === 'macOS'\n\t}\n\n\treturn isMacRegex.test(navigator.userAgent)\n})\n\n/** @public */\nexport const isWindows = runOnce((): boolean => {\n\tif (navigator.userAgentData?.platform) {\n\t\treturn navigator.userAgentData.platform === 'Windows'\n\t}\n\n\treturn isWindowsRegex.test(navigator.userAgent)\n})\n\nexport const isAndroid = runOnce((): boolean => {\n\tif (navigator.userAgentData) {\n\t\treturn navigator.userAgentData.platform === 'Android'\n\t}\n\n\treturn isAndroidRegex.test(navigator.userAgent)\n})\n\nexport const isChromiumBased = runOnce((): boolean => {\n\t// All of our supported Chromium versions will have this property\n\tif (navigator.userAgentData) {\n\t\treturn navigator.userAgentData.brands.some((brand) =>\n\t\t\tbrand.brand.toLowerCase().includes('chromium'),\n\t\t)\n\t}\n\n\treturn false\n})\n\n/**\n * Returns whether the primary modifier key is pressed.\n * On Mac this is the Meta key (Cmd), on Windows/Linux it's the Ctrl key.\n * @public\n */\nexport const isPrimaryModifierKey = (event: KeyboardEvent | MouseEvent): boolean => {\n\tif (isMac()) {\n\t\treturn event.metaKey\n\t}\n\n\treturn event.ctrlKey\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/wait.ts",
    "content": "/** @public */\nexport const wait = (duration: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tsetTimeout(resolve, duration)\n\t})\n"
  },
  {
    "path": "src/lib/helpers/virtualizer.svelte.ts",
    "content": "import { Virtualizer, type VirtualizerOptions } from '@tanstack/virtual-core'\n\nexport * from '@tanstack/virtual-core'\n\nexport function createVirtualizerBase<\n\tTScrollElement extends Element | Window,\n\tTItemElement extends Element,\n>(\n\toptions: () => VirtualizerOptions<TScrollElement, TItemElement>,\n): Virtualizer<TScrollElement, TItemElement> {\n\tconst instance = new Virtualizer<TScrollElement, TItemElement>(options())\n\n\tlet virtualItems = $state(instance.getVirtualItems())\n\tlet totalSize = $state(instance.getTotalSize())\n\n\tconst virtualizer = new Proxy(instance, {\n\t\tget(target, prop) {\n\t\t\tswitch (prop) {\n\t\t\t\tcase 'getVirtualItems':\n\t\t\t\t\treturn () => virtualItems\n\t\t\t\tcase 'getTotalSize':\n\t\t\t\t\treturn () => totalSize\n\t\t\t\tdefault:\n\t\t\t\t\treturn Reflect.get(target, prop)\n\t\t\t}\n\t\t},\n\t})\n\n\t$effect(() => {\n\t\tconst cleanup = untrack(() => {\n\t\t\tconst cleanupInner = virtualizer._didMount()\n\n\t\t\treturn cleanupInner\n\t\t})\n\n\t\treturn cleanup\n\t})\n\n\t$effect(() => {\n\t\tconst resolvedOptions = options()\n\n\t\tvirtualizer.setOptions({\n\t\t\t...resolvedOptions,\n\t\t\tonChange: (instance, sync) => {\n\t\t\t\tinstance._willUpdate()\n\t\t\t\tvirtualItems = instance.getVirtualItems()\n\t\t\t\ttotalSize = instance.getTotalSize()\n\t\t\t\tresolvedOptions.onChange?.(instance, sync)\n\t\t\t},\n\t\t})\n\n\t\tvirtualizer.measure()\n\t})\n\n\treturn virtualizer\n}\n"
  },
  {
    "path": "src/lib/layout-bottom-bar.svelte.ts",
    "content": "import { createContext } from 'svelte'\nimport { SvelteMap } from 'svelte/reactivity'\n\nexport interface BottomBarState {\n\tbottomBar: Snippet | null\n\tabovePlayer: SvelteMap<number, Snippet>\n}\n\nconst [getContext, setContext] = createContext<BottomBarState>()\n\nexport const setupOverlaySnippets = () => {\n\tconst state: BottomBarState = $state({\n\t\tbottomBar: null,\n\t\tabovePlayer: new SvelteMap<number, Snippet>(),\n\t})\n\n\tsetContext(state)\n\n\treturn {\n\t\tget bottomBar(): BottomBarState['bottomBar'] {\n\t\t\treturn state.bottomBar\n\t\t},\n\t\tget abovePlayer(): Snippet[] {\n\t\t\treturn [...state.abovePlayer.values()]\n\t\t},\n\t}\n}\n\nlet counter = 0\n\nexport const useSetOverlaySnippet = (\n\ttype: 'bottom-bar' | 'above-player',\n\tgetSnippet: () => Snippet | null,\n): void => {\n\tconst state = getContext()\n\tconst id = counter\n\tcounter += 1\n\n\t$effect.pre(() => {\n\t\tif (type === 'bottom-bar') {\n\t\t\tstate.bottomBar = getSnippet()\n\n\t\t\treturn () => {\n\t\t\t\tstate.bottomBar = null\n\t\t\t}\n\t\t}\n\n\t\tif (type === 'above-player') {\n\t\t\tconst snippet = getSnippet()\n\n\t\t\tif (snippet) {\n\t\t\t\tstate.abovePlayer.set(id, snippet)\n\t\t\t} else {\n\t\t\t\tstate.abovePlayer.delete(id)\n\t\t\t}\n\n\t\t\treturn () => {\n\t\t\t\tstate.abovePlayer.delete(id)\n\t\t\t}\n\t\t}\n\n\t\treturn undefined\n\t})\n}\n"
  },
  {
    "path": "src/lib/library/__tests__/play-history.test.ts",
    "content": "import 'fake-indexeddb/auto'\nimport { afterEach, describe, expect, it, vi } from 'vitest'\nimport { getDatabase } from '$lib/db/database.ts'\nimport { clearDatabaseStores } from '$lib/helpers/test-helpers.ts'\nimport { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts'\nimport { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts'\n\nconst seedTrack = async (id: number) => {\n\tconst db = await getDatabase()\n\tconst trackData: Track = {\n\t\tid,\n\t\tuuid: `track-${id}`,\n\t\tname: `Track ${id}`,\n\t\tartists: ['Artist'],\n\t\talbum: 'Album',\n\t\tyear: '2026',\n\t\tduration: 180,\n\t\tgenre: [],\n\t\ttrackNo: 1,\n\t\ttrackOf: 1,\n\t\tdiscNo: 1,\n\t\tdiscOf: 1,\n\t\tfileName: `track-${id}.mp3`,\n\t\tdirectory: LEGACY_NO_NATIVE_DIRECTORY,\n\t\tscannedAt: Date.now(),\n\t\tfile: new File(['x'], `track-${id}.mp3`, { type: 'audio/mpeg' }),\n\t}\n\n\tawait db.add('tracks', trackData)\n}\n\ndescribe('play history actions', () => {\n\tafterEach(async () => {\n\t\tvi.restoreAllMocks()\n\t\tawait clearDatabaseStores()\n\t})\n\n\tit('keeps only one entry per track id', async () => {\n\t\tlet now = 100\n\t\tvi.spyOn(Date, 'now').mockImplementation(() => {\n\t\t\tnow += 1\n\t\t\treturn now\n\t\t})\n\n\t\tawait seedTrack(1)\n\t\tawait seedTrack(2)\n\n\t\tawait dbAddToPlayHistory(1)\n\t\tawait dbAddToPlayHistory(2)\n\t\tawait dbAddToPlayHistory(1)\n\n\t\tconst db = await getDatabase()\n\t\tconst entries = await db.getAllFromIndex('playHistory', 'playedAt')\n\n\t\texpect(entries).toHaveLength(2)\n\t\texpect(entries.filter((entry) => entry.trackId === 1)).toHaveLength(1)\n\t\texpect(entries.map((entry) => entry.trackId)).toEqual([2, 1])\n\t})\n\n\tit('does not increase history size when replaying the same track', async () => {\n\t\tlet now = 200\n\t\tvi.spyOn(Date, 'now').mockImplementation(() => {\n\t\t\tnow += 1\n\t\t\treturn now\n\t\t})\n\n\t\tawait seedTrack(10)\n\n\t\tawait dbAddToPlayHistory(10)\n\t\tawait dbAddToPlayHistory(10)\n\t\tawait dbAddToPlayHistory(10)\n\n\t\tconst db = await getDatabase()\n\t\tconst entries = await db.getAll('playHistory')\n\n\t\texpect(entries).toHaveLength(1)\n\t\texpect(entries[0]?.trackId).toBe(10)\n\t})\n\n\tit('keeps only the most recent 100 history entries', async () => {\n\t\tlet now = 300\n\t\tvi.spyOn(Date, 'now').mockImplementation(() => {\n\t\t\tnow += 1\n\t\t\treturn now\n\t\t})\n\n\t\tfor (let trackId = 1; trackId <= 120; trackId += 1) {\n\t\t\tawait seedTrack(trackId)\n\t\t\tawait dbAddToPlayHistory(trackId)\n\t\t}\n\n\t\tconst db = await getDatabase()\n\t\tconst entries = await db.getAllFromIndex('playHistory', 'playedAt')\n\t\tconst trackIds = entries.map((entry) => entry.trackId)\n\n\t\texpect(entries).toHaveLength(100)\n\t\texpect(trackIds[0]).toBe(21)\n\t\texpect(trackIds.at(-1)).toBe(120)\n\t\texpect(trackIds).not.toContain(20)\n\t})\n})\n"
  },
  {
    "path": "src/lib/library/__tests__/playlists.test.ts",
    "content": "import 'fake-indexeddb/auto'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getDatabase } from '$lib/db/database.ts'\nimport {\n\tclearDatabaseStores,\n\tdbGetAllAndExpectLength,\n\texpectToBeDefined,\n} from '$lib/helpers/test-helpers.ts'\nimport {\n\tcreatePlaylist,\n\tdbAddTracksToPlaylistsWithTx,\n\tdbBatchModifyPlaylistsSelection,\n\tdbCreatePlaylist,\n\tdbRemovePlaylist,\n\tdbRemoveTracksFromPlaylistsWithTx,\n\tgetPlaylistEntriesDatabaseStore,\n\tremoveTrackEntryFromPlaylist,\n\ttoggleFavoriteTrack,\n\ttype UpdatePlaylistOptions,\n\tupdatePlaylist,\n} from '$lib/library/playlists-actions.ts'\nimport { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts'\nimport { FAVORITE_PLAYLIST_ID, type UnknownTrack } from '$lib/library/types.ts'\n\nvi.mock('$lib/components/snackbar/snackbar', () => ({\n\tsnackbar: Object.assign(vi.fn(), {\n\t\tunexpectedError: vi.fn(),\n\t}),\n}))\n\nlet uuidCounter = 0\n\nvi.stubGlobal('crypto', {\n\trandomUUID: vi.fn(() => {\n\t\tuuidCounter += 1\n\t\treturn `test-uuid-${uuidCounter}`\n\t}),\n})\n\nvi.stubGlobal('Date', {\n\tnow: vi.fn(() => 1_234_567_890),\n})\n\nlet trackCounter = 0\n\nconst dbImportTestTrack = async (overrides: Partial<UnknownTrack> = {}): Promise<number> => {\n\ttrackCounter += 1\n\tconst trackData: UnknownTrack = {\n\t\tuuid: `test-track-uuid-${trackCounter}`,\n\t\tname: `Test Track ${trackCounter}`,\n\t\talbum: 'Test Album',\n\t\tartists: ['Test Artist'],\n\t\tyear: '2023',\n\t\tduration: 180,\n\t\ttrackNo: 1,\n\t\ttrackOf: 10,\n\t\tdiscNo: 1,\n\t\tdiscOf: 1,\n\t\tgenre: ['Rock'],\n\t\tfile: new File(['test'], 'test.mp3', { type: 'audio/mp3' }) as UnknownTrack['file'],\n\t\tscannedAt: Date.now(),\n\t\tfileName: `test-${trackCounter}.mp3`,\n\t\tdirectory: 1,\n\t\t...overrides,\n\t}\n\n\treturn await dbImportTrack(trackData, undefined)\n}\n\ndescribe('playlists', () => {\n\tbeforeEach(async () => {\n\t\tawait clearDatabaseStores()\n\t\ttrackCounter = 0\n\t\tuuidCounter = 0\n\t})\n\n\tafterEach(() => {\n\t\tvi.clearAllMocks()\n\t})\n\n\tdescribe('playlist creation', () => {\n\t\tit('creates new playlist with all fields', async () => {\n\t\t\tawait dbCreatePlaylist('Test Playlist', 'My description')\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [playlist] = await db.getAll('playlists')\n\n\t\t\texpect(playlist?.name).toBe('Test Playlist')\n\t\t\texpect(playlist?.description).toBe('My description')\n\t\t\texpect(playlist?.uuid).toBe('test-uuid-1')\n\t\t\texpect(playlist?.createdAt).toBe(1_234_567_890)\n\t\t})\n\t})\n\n\tdescribe('UI wrapper functions', () => {\n\t\tit('creates playlist via UI wrapper', async () => {\n\t\t\tawait createPlaylist('UI Playlist', 'Created via UI')\n\n\t\t\tawait dbGetAllAndExpectLength('playlists', 1)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [playlist] = await db.getAll('playlists')\n\n\t\t\texpectToBeDefined(playlist)\n\t\t\texpect(playlist.name).toBe('UI Playlist')\n\t\t\texpect(playlist.description).toBe('Created via UI')\n\t\t})\n\n\t\tit('removes track entry from playlist via UI action', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\t\t\tconst playlistId = await dbCreatePlaylist('Test Playlist', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlistId],\n\t\t\t\ttrackIds: [trackId],\n\t\t\t})\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [entry] = await db.getAll('playlistEntries')\n\t\t\texpectToBeDefined(entry)\n\n\t\t\tawait removeTrackEntryFromPlaylist(entry.id)\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\t\t})\n\t})\n\n\tdescribe('playlist updates', () => {\n\t\tit('updates playlist name and description', async () => {\n\t\t\tconst playlistId = await dbCreatePlaylist('Original Name', 'Original description')\n\n\t\t\tconst updateOptions: UpdatePlaylistOptions = {\n\t\t\t\tid: playlistId,\n\t\t\t\tname: 'Updated Name',\n\t\t\t\tdescription: 'Updated description',\n\t\t\t}\n\n\t\t\tconst result = await updatePlaylist(updateOptions)\n\n\t\t\texpect(result).toBe(true)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [playlist] = await db.getAll('playlists')\n\n\t\t\texpectToBeDefined(playlist)\n\t\t\texpect(playlist.name).toBe('Updated Name')\n\t\t\texpect(playlist.description).toBe('Updated description')\n\t\t\texpect(playlist.uuid).toBe('test-uuid-1')\n\t\t\texpect(playlist.createdAt).toBe(1_234_567_890)\n\t\t})\n\n\t\tit('fails to update non-existent playlist', async () => {\n\t\t\tconst updateOptions: UpdatePlaylistOptions = {\n\t\t\t\tid: 999,\n\t\t\t\tname: 'Non-existent',\n\t\t\t\tdescription: 'Should fail',\n\t\t\t}\n\n\t\t\tconst result = await updatePlaylist(updateOptions)\n\n\t\t\texpect(result).toBe(false)\n\t\t})\n\t})\n\n\tdescribe('playlist removal', () => {\n\t\tit('removes playlist and associated entries', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\t\t\tconst playlistId = await dbCreatePlaylist('Test Playlist', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlistId],\n\t\t\t\ttrackIds: [trackId],\n\t\t\t})\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 1)\n\n\t\t\tawait dbRemovePlaylist(playlistId)\n\n\t\t\tawait dbGetAllAndExpectLength('playlists', 0)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\t\t})\n\n\t\tit('removes only entries for specific playlist', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\t\t\tconst playlist1Id = await dbCreatePlaylist('Playlist 1', '')\n\t\t\tconst playlist2Id = await dbCreatePlaylist('Playlist 2', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlist1Id, playlist2Id],\n\t\t\t\ttrackIds: [trackId],\n\t\t\t})\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 2)\n\n\t\t\tawait dbRemovePlaylist(playlist1Id)\n\n\t\t\tawait dbGetAllAndExpectLength('playlists', 1)\n\t\t\tconst [remainingEntry] = await dbGetAllAndExpectLength('playlistEntries', 1)\n\t\t\texpectToBeDefined(remainingEntry)\n\t\t\texpect(remainingEntry.playlistId).toBe(playlist2Id)\n\t\t})\n\t})\n\n\tdescribe('playlist entries management', () => {\n\t\tit('adds tracks to multiple playlists', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2' })\n\t\t\tconst playlist1Id = await dbCreatePlaylist('Playlist 1', '')\n\t\t\tconst playlist2Id = await dbCreatePlaylist('Playlist 2', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tconst changes = await dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlist1Id, playlist2Id],\n\t\t\t\ttrackIds: [track1Id, track2Id],\n\t\t\t})\n\n\t\t\texpect(changes).toHaveLength(4)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 4)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst entries = await db.getAll('playlistEntries')\n\t\t\tconst playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id)\n\t\t\tconst playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id)\n\n\t\t\texpect(playlist1Entries).toHaveLength(2)\n\t\t\texpect(playlist2Entries).toHaveLength(2)\n\t\t})\n\n\t\tit('removes tracks from specific playlists', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2' })\n\t\t\tconst playlist1Id = await dbCreatePlaylist('Playlist 1', '')\n\t\t\tconst playlist2Id = await dbCreatePlaylist('Playlist 2', '')\n\n\t\t\tlet store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlist1Id, playlist2Id],\n\t\t\t\ttrackIds: [track1Id, track2Id],\n\t\t\t})\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 4)\n\n\t\t\tstore = await getPlaylistEntriesDatabaseStore()\n\t\t\tconst changes = await dbRemoveTracksFromPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlist1Id],\n\t\t\t\ttrackIds: [track1Id],\n\t\t\t})\n\n\t\t\texpect(changes).toHaveLength(1)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 3)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst entries = await db.getAll('playlistEntries')\n\t\t\tconst hasTrack1InPlaylist1 = entries.some(\n\t\t\t\t(e) => e.playlistId === playlist1Id && e.trackId === track1Id,\n\t\t\t)\n\t\t\texpect(hasTrack1InPlaylist1).toBe(false)\n\t\t})\n\n\t\tit('batch modifies playlist selections', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2' })\n\t\t\tconst playlist1Id = await dbCreatePlaylist('Playlist 1', '')\n\t\t\tconst playlist2Id = await dbCreatePlaylist('Playlist 2', '')\n\t\t\tconst playlist3Id = await dbCreatePlaylist('Playlist 3', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlist1Id],\n\t\t\t\ttrackIds: [track1Id, track2Id],\n\t\t\t})\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 2)\n\n\t\t\tconst result = await dbBatchModifyPlaylistsSelection({\n\t\t\t\ttrackIds: [track1Id, track2Id],\n\t\t\t\tplaylistsIdsAddTo: [playlist2Id, playlist3Id],\n\t\t\t\tplaylistsIdsRemoveFrom: [playlist1Id],\n\t\t\t})\n\n\t\t\texpect(result).toBe(true)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 4)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst entries = await db.getAll('playlistEntries')\n\t\t\tconst playlist1Entries = entries.filter((e) => e.playlistId === playlist1Id)\n\t\t\tconst playlist2Entries = entries.filter((e) => e.playlistId === playlist2Id)\n\t\t\tconst playlist3Entries = entries.filter((e) => e.playlistId === playlist3Id)\n\n\t\t\texpect(playlist1Entries).toHaveLength(0)\n\t\t\texpect(playlist2Entries).toHaveLength(2)\n\t\t\texpect(playlist3Entries).toHaveLength(2)\n\t\t})\n\n\t\tit('returns false when no changes are made', async () => {\n\t\t\tconst result = await dbBatchModifyPlaylistsSelection({\n\t\t\t\ttrackIds: [],\n\t\t\t\tplaylistsIdsAddTo: [],\n\t\t\t\tplaylistsIdsRemoveFrom: [],\n\t\t\t})\n\n\t\t\texpect(result).toBe(false)\n\t\t})\n\t})\n\n\tdescribe('favorites functionality', () => {\n\t\tit('adds track to favorites', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\n\t\t\tawait toggleFavoriteTrack(false, trackId)\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [entry] = await db.getAll('playlistEntries')\n\n\t\t\texpectToBeDefined(entry)\n\t\t\texpect(entry.playlistId).toBe(FAVORITE_PLAYLIST_ID)\n\t\t\texpect(entry.trackId).toBe(trackId)\n\t\t\texpect(entry.addedAt).toBe(1_234_567_890)\n\t\t})\n\n\t\tit('removes track from favorites', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\n\t\t\tawait toggleFavoriteTrack(false, trackId)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 1)\n\n\t\t\tawait toggleFavoriteTrack(true, trackId)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\t\t})\n\t})\n\n\tdescribe('playlist entries data structure', () => {\n\t\tit('creates entries with correct structure', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\t\t\tconst playlistId = await dbCreatePlaylist('Test Playlist', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlistId],\n\t\t\t\ttrackIds: [trackId],\n\t\t\t})\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst [entry] = await db.getAll('playlistEntries')\n\n\t\t\texpect(entry).toEqual({\n\t\t\t\tid: expect.any(Number),\n\t\t\t\tplaylistId,\n\t\t\t\ttrackId,\n\t\t\t\taddedAt: 1_234_567_890,\n\t\t\t})\n\t\t})\n\n\t\tit('maintains chronological order of entries', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2' })\n\t\t\tconst playlistId = await dbCreatePlaylist('Test Playlist', '')\n\n\t\t\tconst store = await getPlaylistEntriesDatabaseStore()\n\n\t\t\tvi.mocked(Date.now).mockReturnValueOnce(1000)\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlistId],\n\t\t\t\ttrackIds: [track1Id],\n\t\t\t})\n\n\t\t\tvi.mocked(Date.now).mockReturnValueOnce(2000)\n\t\t\tawait dbAddTracksToPlaylistsWithTx(store, {\n\t\t\t\tplaylistIds: [playlistId],\n\t\t\t\ttrackIds: [track2Id],\n\t\t\t})\n\n\t\t\tconst db = await getDatabase()\n\t\t\tconst entries = await db.getAll('playlistEntries')\n\t\t\tentries.sort((a, b) => a.addedAt - b.addedAt)\n\n\t\t\texpect(entries).toHaveLength(2)\n\t\t\texpect(entries[0]?.trackId).toBe(track1Id)\n\t\t\texpect(entries[0]?.addedAt).toBe(1000)\n\t\t\texpect(entries[1]?.trackId).toBe(track2Id)\n\t\t\texpect(entries[1]?.addedAt).toBe(2000)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/library/__tests__/remove.test.ts",
    "content": "import 'fake-indexeddb/auto'\nimport { beforeEach, describe, expect, it } from 'vitest'\nimport { getDatabase } from '$lib/db/database.ts'\nimport {\n\tclearDatabaseStores,\n\tdbGetAllAndExpectLength,\n\texpectToBeDefined,\n} from '$lib/helpers/test-helpers.ts'\nimport { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts'\nimport { dbCreatePlaylist } from '$lib/library/playlists-actions.ts'\nimport { dbRemoveAlbum, dbRemoveArtist, dbRemoveTracks } from '$lib/library/remove.ts'\nimport { dbImportTrack } from '$lib/library/scan-actions/scanner/import-track.ts'\nimport type { PlaylistEntry, UnknownTrack } from '$lib/library/types.ts'\n\nconst dbImportTestTrack = (overrides: Partial<UnknownTrack> = {}): Promise<number> => {\n\tconst trackData: UnknownTrack = {\n\t\tuuid: crypto.randomUUID(),\n\t\tname: 'Test Track',\n\t\talbum: 'Test Album',\n\t\tartists: ['Test Artist'],\n\t\tyear: '2023',\n\t\tduration: 180,\n\t\ttrackNo: 1,\n\t\ttrackOf: 10,\n\t\tdiscNo: 1,\n\t\tdiscOf: 1,\n\t\tgenre: ['Rock'],\n\t\tfile: new File(['test'], 'test.mp3', { type: 'audio/mp3' }),\n\t\tscannedAt: Date.now(),\n\t\tfileName: 'test.mp3',\n\t\tdirectory: 1,\n\t\t...overrides,\n\t}\n\n\treturn dbImportTrack(trackData, undefined)\n}\n\nconst createTestPlaylist = async (name = 'Test Playlist'): Promise<number> =>\n\tdbCreatePlaylist(name, '')\n\nconst addTrackToPlaylist = async (playlistId: number, trackId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst playlistEntry: Omit<PlaylistEntry, 'id'> = {\n\t\tplaylistId,\n\t\ttrackId,\n\t\taddedAt: Date.now(),\n\t}\n\tawait db.add('playlistEntries', playlistEntry as PlaylistEntry)\n}\n\nconst addTracksToPlaylist = async (\n\tplaylistId: number,\n\ttrackIds: readonly number[],\n): Promise<void> => {\n\tfor (const trackId of trackIds) {\n\t\tawait addTrackToPlaylist(playlistId, trackId)\n\t}\n}\n\nconst addTracksToPlayHistory = async (trackIds: readonly number[]): Promise<void> => {\n\tfor (const trackId of trackIds) {\n\t\tawait dbAddToPlayHistory(trackId)\n\t}\n}\n\nconst expectOnlyTrackWithReferences = async (trackId: number): Promise<void> => {\n\tconst tracksAfter = await dbGetAllAndExpectLength('tracks', 1)\n\texpect(tracksAfter[0]?.id).toBe(trackId)\n\n\tconst playlistEntriesAfter = await dbGetAllAndExpectLength('playlistEntries', 1)\n\texpect(playlistEntriesAfter[0]?.trackId).toBe(trackId)\n\n\tconst playHistoryAfter = await dbGetAllAndExpectLength('playHistory', 1)\n\texpect(playHistoryAfter[0]?.trackId).toBe(trackId)\n}\n\ndescribe('remove functions', () => {\n\tbeforeEach(async () => {\n\t\tawait clearDatabaseStores()\n\t})\n\n\tdescribe('dbRemoveTracks', () => {\n\t\tit('should remove a track and clean up unused album and artist', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\n\t\t\tconst db = await getDatabase()\n\n\t\t\t// Verify track, album, and artist were created\n\t\t\tawait dbGetAllAndExpectLength('tracks', 1)\n\n\t\t\tconst albums = await dbGetAllAndExpectLength('albums', 1)\n\t\t\texpect(albums[0]?.name).toBe('Test Album')\n\n\t\t\tconst artists = await dbGetAllAndExpectLength('artists', 1)\n\t\t\texpect(artists[0]?.name).toBe('Test Artist')\n\n\t\t\t// Remove the track\n\t\t\tawait dbRemoveTracks([trackId])\n\n\t\t\t// Verify track is removed\n\t\t\tconst removedTrack = await db.get('tracks', trackId)\n\t\t\texpect(removedTrack).toBeUndefined()\n\n\t\t\t// Verify album and artist are also removed (cleanup)\n\t\t\tawait dbGetAllAndExpectLength('albums', 0)\n\t\t\tawait dbGetAllAndExpectLength('artists', 0)\n\t\t})\n\n\t\tit('should not remove album or artist if still referenced by other tracks', async () => {\n\t\t\t// Create two tracks with the same album and artist\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2' })\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 2)\n\t\t\tawait dbGetAllAndExpectLength('albums', 1)\n\t\t\tawait dbGetAllAndExpectLength('artists', 1)\n\n\t\t\tawait dbRemoveTracks([track1Id])\n\n\t\t\t// Verify only one track is removed\n\t\t\tconst tracksAfter = await dbGetAllAndExpectLength('tracks', 1)\n\t\t\texpect(tracksAfter[0]?.id).toBe(track2Id)\n\n\t\t\t// Verify album and artist are still there\n\t\t\tawait dbGetAllAndExpectLength('albums', 1)\n\t\t\tawait dbGetAllAndExpectLength('artists', 1)\n\t\t})\n\n\t\tit('should remove track from all playlists when removing the track', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\n\t\t\tconst playlistId1 = await createTestPlaylist('Test Playlist 1')\n\t\t\tconst playlistId2 = await createTestPlaylist('Test Playlist 2')\n\n\t\t\tawait dbGetAllAndExpectLength('playlists', 2)\n\t\t\tawait addTracksToPlaylist(playlistId1, [trackId])\n\t\t\tawait addTracksToPlaylist(playlistId2, [trackId])\n\n\t\t\tconst playlistEntries = await dbGetAllAndExpectLength('playlistEntries', 2)\n\t\t\texpect(playlistEntries.every((entry) => entry.trackId === trackId)).toBe(true)\n\n\t\t\tawait dbRemoveTracks([trackId])\n\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\n\t\t\tawait dbGetAllAndExpectLength('playlists', 2)\n\t\t})\n\n\t\tit('should remove deleted tracks from play history and keep unrelated entries', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({ name: 'Track 2', album: 'Album 2' })\n\n\t\t\tawait dbAddToPlayHistory(track1Id)\n\t\t\tawait dbAddToPlayHistory(track2Id)\n\n\t\t\tawait dbGetAllAndExpectLength('playHistory', 2)\n\n\t\t\tawait dbRemoveTracks([track1Id])\n\n\t\t\tconst historyEntries = await dbGetAllAndExpectLength('playHistory', 1)\n\t\t\texpect(historyEntries[0]?.trackId).toBe(track2Id)\n\t\t})\n\n\t\tit('should handle removing non-existent track gracefully', async () => {\n\t\t\t// Try to remove a track that doesn't exist\n\t\t\tawait expect(dbRemoveTracks([999])).resolves.toBeUndefined()\n\t\t})\n\n\t\tit('should ignore duplicate track ids in one removal request', async () => {\n\t\t\tconst trackId = await dbImportTestTrack()\n\n\t\t\tconst playlistId = await createTestPlaylist()\n\n\t\t\tawait addTracksToPlaylist(playlistId, [trackId, trackId])\n\t\t\tawait addTracksToPlayHistory([trackId])\n\n\t\t\tawait dbRemoveTracks([trackId, trackId])\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 0)\n\t\t\tawait dbGetAllAndExpectLength('albums', 0)\n\t\t\tawait dbGetAllAndExpectLength('artists', 0)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\t\t\tawait dbGetAllAndExpectLength('playHistory', 0)\n\t\t})\n\n\t\tit('should remove existing tracks and ignore missing ids in the same request', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst track2Id = await dbImportTestTrack({\n\t\t\t\tname: 'Track 2',\n\t\t\t\talbum: 'Album 2',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t})\n\n\t\t\tconst playlistId = await createTestPlaylist()\n\n\t\t\tawait addTracksToPlaylist(playlistId, [track1Id, track2Id])\n\t\t\tawait addTracksToPlayHistory([track1Id, track2Id])\n\n\t\t\tawait dbRemoveTracks([track1Id, 999])\n\n\t\t\tawait expectOnlyTrackWithReferences(track2Id)\n\n\t\t\tconst albumsAfter = await dbGetAllAndExpectLength('albums', 1)\n\t\t\texpect(albumsAfter[0]?.name).toBe('Album 2')\n\n\t\t\tconst artistsAfter = await dbGetAllAndExpectLength('artists', 1)\n\t\t\texpect(artistsAfter[0]?.name).toBe('Artist 2')\n\t\t})\n\n\t\tit('should remove multiple tracks in one operation and clean up shared data once unused', async () => {\n\t\t\tconst track1Id = await dbImportTestTrack({ name: 'Track 1', artists: ['Artist 1'] })\n\t\t\tconst track2Id = await dbImportTestTrack({\n\t\t\t\tname: 'Track 2',\n\t\t\t\talbum: 'Album 2',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t})\n\n\t\t\tconst playlistId = await createTestPlaylist()\n\n\t\t\tawait addTracksToPlaylist(playlistId, [track1Id, track2Id])\n\n\t\t\tawait dbRemoveTracks([track1Id, track2Id])\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 0)\n\t\t\tawait dbGetAllAndExpectLength('albums', 0)\n\t\t\tawait dbGetAllAndExpectLength('artists', 0)\n\t\t\tawait dbGetAllAndExpectLength('playlistEntries', 0)\n\t\t\tawait dbGetAllAndExpectLength('playlists', 1)\n\t\t})\n\n\t\tit('should return early for empty input', async () => {\n\t\t\tawait expect(dbRemoveTracks([])).resolves.toBeUndefined()\n\t\t})\n\t})\n\n\tdescribe('dbRemoveAlbum', () => {\n\t\tit('should remove album and all its tracks', async () => {\n\t\t\t// Create two tracks with the same album\n\t\t\tawait dbImportTestTrack({ name: 'Track 1' })\n\t\t\tawait dbImportTestTrack({ name: 'Track 2' })\n\n\t\t\tconst albums = await dbGetAllAndExpectLength('albums', 1)\n\t\t\tconst albumId = albums[0]?.id\n\t\t\texpectToBeDefined(albumId)\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 2)\n\n\t\t\tawait dbRemoveAlbum(albumId)\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 0)\n\t\t\tawait dbGetAllAndExpectLength('albums', 0)\n\t\t})\n\n\t\tit('should handle removing non-existent album gracefully', async () => {\n\t\t\t// Try to remove an album that doesn't exist\n\t\t\tawait expect(dbRemoveAlbum(999)).resolves.toBeUndefined()\n\t\t})\n\n\t\tit('should clear playlists and play history for removed album tracks only', async () => {\n\t\t\tconst albumTrack1Id = await dbImportTestTrack({ name: 'Track 1' })\n\t\t\tconst albumTrack2Id = await dbImportTestTrack({ name: 'Track 2' })\n\t\t\tconst survivorTrackId = await dbImportTestTrack({\n\t\t\t\tname: 'Track 3',\n\t\t\t\talbum: 'Album 2',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t})\n\n\t\t\tconst playlistId = await createTestPlaylist()\n\n\t\t\tawait addTracksToPlaylist(playlistId, [albumTrack1Id, albumTrack2Id, survivorTrackId])\n\n\t\t\tawait addTracksToPlayHistory([albumTrack1Id, albumTrack2Id, survivorTrackId])\n\n\t\t\tconst albums = await dbGetAllAndExpectLength('albums', 2)\n\t\t\tconst albumId = albums.find((album) => album.name === 'Test Album')?.id\n\t\t\texpectToBeDefined(albumId)\n\n\t\t\tawait dbRemoveAlbum(albumId)\n\n\t\t\tawait expectOnlyTrackWithReferences(survivorTrackId)\n\t\t})\n\n\t\tit('should keep shared artists that are still referenced by survivor tracks from other albums', async () => {\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Album Track 1',\n\t\t\t\talbum: 'Album 1',\n\t\t\t\tartists: ['Shared Artist', 'Album 1 Artist'],\n\t\t\t})\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Album Track 2',\n\t\t\t\talbum: 'Album 1',\n\t\t\t\tartists: ['Shared Artist'],\n\t\t\t})\n\t\t\tconst survivorTrackId = await dbImportTestTrack({\n\t\t\t\tname: 'Survivor Track',\n\t\t\t\talbum: 'Album 2',\n\t\t\t\tartists: ['Shared Artist', 'Album 2 Artist'],\n\t\t\t})\n\n\t\t\tconst albums = await dbGetAllAndExpectLength('albums', 2)\n\t\t\tconst albumId = albums.find((album) => album.name === 'Album 1')?.id\n\t\t\texpectToBeDefined(albumId)\n\n\t\t\tawait dbRemoveAlbum(albumId)\n\n\t\t\tconst tracksAfter = await dbGetAllAndExpectLength('tracks', 1)\n\t\t\texpect(tracksAfter[0]?.id).toBe(survivorTrackId)\n\n\t\t\tconst albumsAfter = await dbGetAllAndExpectLength('albums', 1)\n\t\t\texpect(albumsAfter[0]?.name).toBe('Album 2')\n\n\t\t\tconst artistsAfter = await dbGetAllAndExpectLength('artists', 2)\n\t\t\texpect(artistsAfter.map((artist) => artist.name).sort()).toEqual([\n\t\t\t\t'Album 2 Artist',\n\t\t\t\t'Shared Artist',\n\t\t\t])\n\t\t})\n\t})\n\n\tdescribe('dbRemoveArtist', () => {\n\t\tit('should remove artist and all tracks by that artist', async () => {\n\t\t\t// Create two tracks with the same artist\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Track 1',\n\t\t\t\talbum: 'Album 1',\n\t\t\t})\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Track 2',\n\t\t\t\talbum: 'Album 2',\n\t\t\t})\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 2)\n\t\t\tconst artists = await dbGetAllAndExpectLength('artists', 1)\n\t\t\tconst artistId = artists[0]?.id\n\t\t\texpectToBeDefined(artistId)\n\n\t\t\tawait dbRemoveArtist(artistId)\n\n\t\t\tawait dbGetAllAndExpectLength('tracks', 0)\n\t\t\tawait dbGetAllAndExpectLength('artists', 0)\n\t\t})\n\n\t\tit('should handle removing non-existent artist gracefully', async () => {\n\t\t\tawait expect(dbRemoveArtist(999)).resolves.toBeUndefined()\n\t\t})\n\n\t\tit('should remove tracks with multiple artists correctly', async () => {\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tartists: ['Artist 1', 'Artist 2'],\n\t\t\t\talbum: 'Collaboration Album',\n\t\t\t})\n\t\t\t// Another track with only one of the artists\n\t\t\tconst track2Id = await dbImportTestTrack({\n\t\t\t\tname: 'Track 2',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t\talbum: 'Solo Album',\n\t\t\t})\n\n\t\t\tconst artists = await dbGetAllAndExpectLength('artists', 2)\n\n\t\t\tconst artist1 = artists.find((a) => a.name === 'Artist 1')\n\t\t\texpectToBeDefined(artist1?.id)\n\n\t\t\tawait dbRemoveArtist(artist1.id)\n\n\t\t\tconst tracksAfter = await dbGetAllAndExpectLength('tracks', 1)\n\t\t\texpect(tracksAfter[0]?.id, 'Expected Track 2 to remain').toBe(track2Id)\n\n\t\t\tconst artistsAfter = await dbGetAllAndExpectLength('artists', 1)\n\t\t\texpect(artistsAfter[0]?.name, 'Expected Artist 2 to remain').toBe('Artist 2')\n\t\t})\n\n\t\tit('should clear playlists and play history for removed artist tracks only', async () => {\n\t\t\tconst artistTrack1Id = await dbImportTestTrack({\n\t\t\t\tname: 'Track 1',\n\t\t\t\talbum: 'Album 1',\n\t\t\t\tartists: ['Artist 1'],\n\t\t\t})\n\t\t\tconst artistTrack2Id = await dbImportTestTrack({\n\t\t\t\tname: 'Track 2',\n\t\t\t\talbum: 'Album 2',\n\t\t\t\tartists: ['Artist 1'],\n\t\t\t})\n\t\t\tconst survivorTrackId = await dbImportTestTrack({\n\t\t\t\tname: 'Track 3',\n\t\t\t\talbum: 'Album 3',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t})\n\n\t\t\tconst playlistId = await createTestPlaylist()\n\n\t\t\tawait addTracksToPlaylist(playlistId, [artistTrack1Id, artistTrack2Id, survivorTrackId])\n\n\t\t\tawait addTracksToPlayHistory([artistTrack1Id, artistTrack2Id, survivorTrackId])\n\n\t\t\tconst artists = await dbGetAllAndExpectLength('artists', 2)\n\t\t\tconst artistId = artists.find((artist) => artist.name === 'Artist 1')?.id\n\t\t\texpectToBeDefined(artistId)\n\n\t\t\tawait dbRemoveArtist(artistId)\n\n\t\t\tawait expectOnlyTrackWithReferences(survivorTrackId)\n\t\t})\n\n\t\tit('should keep shared albums that are still referenced by survivor tracks from other artists', async () => {\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Artist 1 Track 1',\n\t\t\t\talbum: 'Shared Album',\n\t\t\t\tartists: ['Artist 1'],\n\t\t\t})\n\t\t\tawait dbImportTestTrack({\n\t\t\t\tname: 'Artist 1 Track 2',\n\t\t\t\talbum: 'Artist 1 Album',\n\t\t\t\tartists: ['Artist 1'],\n\t\t\t})\n\t\t\tconst survivorTrackId = await dbImportTestTrack({\n\t\t\t\tname: 'Artist 2 Survivor',\n\t\t\t\talbum: 'Shared Album',\n\t\t\t\tartists: ['Artist 2'],\n\t\t\t})\n\n\t\t\tconst artists = await dbGetAllAndExpectLength('artists', 2)\n\t\t\tconst artistId = artists.find((artist) => artist.name === 'Artist 1')?.id\n\t\t\texpectToBeDefined(artistId)\n\n\t\t\tawait dbRemoveArtist(artistId)\n\n\t\t\tconst tracksAfter = await dbGetAllAndExpectLength('tracks', 1)\n\t\t\texpect(tracksAfter[0]?.id).toBe(survivorTrackId)\n\n\t\t\tconst albumsAfter = await dbGetAllAndExpectLength('albums', 1)\n\t\t\texpect(albumsAfter[0]?.name).toBe('Shared Album')\n\n\t\t\tconst artistsAfter = await dbGetAllAndExpectLength('artists', 1)\n\t\t\texpect(artistsAfter[0]?.name).toBe('Artist 2')\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/library/get/__tests__/value.test.ts",
    "content": "import 'fake-indexeddb/auto'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getDatabase } from '$lib/db/database.ts'\nimport type { DatabaseChangeDetails } from '$lib/db/events.ts'\nimport { clearDatabaseStores } from '$lib/helpers/test-helpers.ts'\nimport {\n\tclearLibraryValueCache,\n\tgetLibraryValue,\n\tLibraryValueNotFoundError,\n\tpreloadLibraryValue,\n\tshouldRefetchLibraryValue,\n} from '$lib/library/get/value.ts'\nimport { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID } from '$lib/library/types.ts'\n\n// Mock crypto.randomUUID for consistent UUIDs\nvi.stubGlobal('crypto', {\n\trandomUUID: vi.fn(() => 'test-uuid-123'),\n})\n\n// Mock Date.now for consistent timestamps\nvi.stubGlobal('Date', {\n\tnow: vi.fn(() => 1_234_567_890),\n})\n\ndescribe('getLibraryValue', () => {\n\tbeforeEach(async () => {\n\t\tawait clearDatabaseStores()\n\t\tclearLibraryValueCache()\n\t})\n\n\tafterEach(() => {\n\t\tvi.clearAllMocks()\n\t})\n\n\tdescribe('tracks', () => {\n\t\tit('should return track data with favorite status false', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\t// Insert a track\n\t\t\tconst trackData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Track',\n\t\t\t\talbum: 'Test Album',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tuuid: 'track-uuid-1',\n\t\t\t\tyear: '2023',\n\t\t\t\tduration: 180,\n\t\t\t\tgenre: ['Rock'],\n\t\t\t\ttrackNo: 1,\n\t\t\t\ttrackOf: 10,\n\t\t\t\tdiscNo: 1,\n\t\t\t\tdiscOf: 1,\n\t\t\t\tfile: {} as File,\n\t\t\t\tscannedAt: 1_234_567_890,\n\t\t\t\tfileName: 'test-track.mp3',\n\t\t\t\tdirectory: 1,\n\t\t\t}\n\n\t\t\tawait db.add('tracks', trackData)\n\n\t\t\tconst result = await getLibraryValue('tracks', 1)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\t...trackData,\n\t\t\t\ttype: 'track',\n\t\t\t\tfavorite: false,\n\t\t\t})\n\t\t})\n\n\t\tit('should return track data with favorite status true when track is in favorites', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\t// Insert a track\n\t\t\tconst trackData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Track',\n\t\t\t\talbum: 'Test Album',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tuuid: 'track-uuid-1',\n\t\t\t\tyear: '2023',\n\t\t\t\tduration: 180,\n\t\t\t\tgenre: ['Rock'],\n\t\t\t\ttrackNo: 1,\n\t\t\t\ttrackOf: 10,\n\t\t\t\tdiscNo: 1,\n\t\t\t\tdiscOf: 1,\n\t\t\t\tfile: {} as File,\n\t\t\t\tscannedAt: 1_234_567_890,\n\t\t\t\tfileName: 'test-track.mp3',\n\t\t\t\tdirectory: 1,\n\t\t\t}\n\n\t\t\tawait db.add('tracks', trackData)\n\n\t\t\t// Add track to favorites\n\t\t\tawait db.add('playlistEntries', {\n\t\t\t\tid: 1,\n\t\t\t\tplaylistId: FAVORITE_PLAYLIST_ID,\n\t\t\t\ttrackId: 1,\n\t\t\t\taddedAt: 1_234_567_890,\n\t\t\t})\n\n\t\t\tconst result = await getLibraryValue('tracks', 1)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\t...trackData,\n\t\t\t\ttype: 'track',\n\t\t\t\tfavorite: true,\n\t\t\t})\n\t\t})\n\n\t\tit('should throw LibraryValueNotFoundError for non-existent track', async () => {\n\t\t\tawait expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t})\n\n\t\tit('should return undefined for non-existent track when allowEmpty is true', async () => {\n\t\t\tconst result = await getLibraryValue('tracks', 999, true)\n\t\t\texpect(result).toBeUndefined()\n\t\t})\n\n\t\tit('should return cached value on subsequent calls', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst trackData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Track',\n\t\t\t\talbum: 'Test Album',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tuuid: 'track-uuid-1',\n\t\t\t\tyear: '2023',\n\t\t\t\tduration: 180,\n\t\t\t\tgenre: ['Rock'],\n\t\t\t\ttrackNo: 1,\n\t\t\t\ttrackOf: 10,\n\t\t\t\tdiscNo: 1,\n\t\t\t\tdiscOf: 1,\n\t\t\t\tfile: {} as File,\n\t\t\t\tscannedAt: 1_234_567_890,\n\t\t\t\tfileName: 'test-track.mp3',\n\t\t\t\tdirectory: 1,\n\t\t\t}\n\n\t\t\tawait db.add('tracks', trackData)\n\n\t\t\t// First call - should fetch from database\n\t\t\tconst result1 = await getLibraryValue('tracks', 1)\n\n\t\t\t// Second call - should return cached value\n\t\t\tconst result2 = await getLibraryValue('tracks', 1)\n\n\t\t\texpect(result1).toEqual(result2)\n\t\t})\n\t})\n\n\tdescribe('albums', () => {\n\t\tit('should return album data', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst albumData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Album',\n\t\t\t\tuuid: 'album-uuid-1',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tyear: '2023',\n\t\t\t\timage: {} as Blob,\n\t\t\t}\n\n\t\t\tawait db.add('albums', albumData)\n\n\t\t\tconst result = await getLibraryValue('albums', 1)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\t...albumData,\n\t\t\t\ttype: 'album',\n\t\t\t})\n\t\t})\n\n\t\tit('should throw LibraryValueNotFoundError for non-existent album', async () => {\n\t\t\tawait expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t})\n\n\t\tit('should return undefined for non-existent album when allowEmpty is true', async () => {\n\t\t\tconst result = await getLibraryValue('albums', 999, true)\n\t\t\texpect(result).toBeUndefined()\n\t\t})\n\t})\n\n\tdescribe('artists', () => {\n\t\tit('should return artist data', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst artistData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Artist',\n\t\t\t\tuuid: 'artist-uuid-1',\n\t\t\t}\n\n\t\t\tawait db.add('artists', artistData)\n\n\t\t\tconst result = await getLibraryValue('artists', 1)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\t...artistData,\n\t\t\t\ttype: 'artist',\n\t\t\t})\n\t\t})\n\n\t\tit('should throw LibraryValueNotFoundError for non-existent artist', async () => {\n\t\t\tawait expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t})\n\n\t\tit('should return undefined for non-existent artist when allowEmpty is true', async () => {\n\t\t\tconst result = await getLibraryValue('artists', 999, true)\n\t\t\texpect(result).toBeUndefined()\n\t\t})\n\t})\n\n\tdescribe('playlists', () => {\n\t\tit('should return playlist data', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst playlistData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Playlist',\n\t\t\t\tdescription: '',\n\t\t\t\tuuid: 'playlist-uuid-1',\n\t\t\t\tcreatedAt: 1_234_567_890,\n\t\t\t}\n\n\t\t\tawait db.add('playlists', playlistData)\n\n\t\t\tconst result = await getLibraryValue('playlists', 1)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\t...playlistData,\n\t\t\t\ttype: 'playlist',\n\t\t\t})\n\t\t})\n\n\t\tit('should return favorite playlist for FAVORITE_PLAYLIST_ID', async () => {\n\t\t\tconst result = await getLibraryValue('playlists', FAVORITE_PLAYLIST_ID)\n\n\t\t\texpect(result).toEqual({\n\t\t\t\ttype: 'playlist',\n\t\t\t\tid: FAVORITE_PLAYLIST_ID,\n\t\t\t\tuuid: FAVORITE_PLAYLIST_UUID,\n\t\t\t\tdescription: '',\n\t\t\t\tname: 'Favorites',\n\t\t\t\tcreatedAt: 0,\n\t\t\t})\n\t\t})\n\n\t\tit('should throw LibraryValueNotFoundError for non-existent playlist', async () => {\n\t\t\tawait expect(getLibraryValue('playlists', 999)).rejects.toThrow(\n\t\t\t\tLibraryValueNotFoundError,\n\t\t\t)\n\t\t})\n\n\t\tit('should return undefined for non-existent playlist when allowEmpty is true', async () => {\n\t\t\tconst result = await getLibraryValue('playlists', 999, true)\n\t\t\texpect(result).toBeUndefined()\n\t\t})\n\t})\n\n\tdescribe('preloadLibraryValue', () => {\n\t\tit('should preload track value into cache', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst trackData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Track',\n\t\t\t\talbum: 'Test Album',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tuuid: 'track-uuid-1',\n\t\t\t\tyear: '2023',\n\t\t\t\tduration: 180,\n\t\t\t\tgenre: ['Rock'],\n\t\t\t\ttrackNo: 1,\n\t\t\t\ttrackOf: 10,\n\t\t\t\tdiscNo: 1,\n\t\t\t\tdiscOf: 1,\n\t\t\t\tfile: {} as File,\n\t\t\t\tscannedAt: 1_234_567_890,\n\t\t\t\tfileName: 'test-track.mp3',\n\t\t\t\tdirectory: 1,\n\t\t\t}\n\n\t\t\tawait db.add('tracks', trackData)\n\n\t\t\t// Preload the value\n\t\t\tawait preloadLibraryValue('tracks', 1)\n\n\t\t\t// This should now return synchronously from cache\n\t\t\tconst result = getLibraryValue('tracks', 1)\n\n\t\t\t// If it's synchronous, it should not be a Promise\n\t\t\texpect(result).not.toBeInstanceOf(Promise)\n\t\t\texpect(result).toEqual({\n\t\t\t\t...trackData,\n\t\t\t\ttype: 'track',\n\t\t\t\tfavorite: false,\n\t\t\t})\n\t\t})\n\n\t\tit('should not throw error for non-existent value', async () => {\n\t\t\t// Should not throw even though the value doesn't exist\n\t\t\tawait expect(preloadLibraryValue('tracks', 999)).resolves.toBeUndefined()\n\t\t})\n\t})\n\n\tdescribe('shouldRefetchLibraryValue', () => {\n\t\tit('should return true when track is updated', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'tracks',\n\t\t\t\t\toperation: 'update',\n\t\t\t\t\tkey: 1,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('tracks', 1, changes)\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\tit('should return true when track favorite status changes', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'playlistEntries',\n\t\t\t\t\toperation: 'add',\n\t\t\t\t\tkey: 1,\n\t\t\t\t\tvalue: {\n\t\t\t\t\t\tid: 1,\n\t\t\t\t\t\tplaylistId: FAVORITE_PLAYLIST_ID,\n\t\t\t\t\t\ttrackId: 1,\n\t\t\t\t\t\taddedAt: 1_234_567_890,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('tracks', 1, changes)\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\tit('should return false when unrelated changes occur', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'tracks',\n\t\t\t\t\toperation: 'update',\n\t\t\t\t\tkey: 2,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('tracks', 1, changes)\n\t\t\texpect(result).toBe(false)\n\t\t})\n\n\t\tit('should return true when album is updated', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'albums',\n\t\t\t\t\toperation: 'update',\n\t\t\t\t\tkey: 1,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('albums', 1, changes)\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\tit('should return true when artist is deleted', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'artists',\n\t\t\t\t\toperation: 'delete',\n\t\t\t\t\tkey: 1,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('artists', 1, changes)\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\tit('should return true when playlist is updated', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'playlists',\n\t\t\t\t\toperation: 'update',\n\t\t\t\t\tkey: 1,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('playlists', 1, changes)\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\tit('should return false when no relevant changes occur', () => {\n\t\t\tconst changes: readonly DatabaseChangeDetails[] = [\n\t\t\t\t{\n\t\t\t\t\tstoreName: 'albums',\n\t\t\t\t\toperation: 'update',\n\t\t\t\t\tkey: 2,\n\t\t\t\t},\n\t\t\t]\n\n\t\t\tconst result = shouldRefetchLibraryValue('tracks', 1, changes)\n\t\t\texpect(result).toBe(false)\n\t\t})\n\t})\n\n\tdescribe('LibraryValueNotFoundError', () => {\n\t\tit('should have correct message and name', () => {\n\t\t\tconst error = new LibraryValueNotFoundError('tracks:1')\n\n\t\t\texpect(error.message).toBe('Value not found. Cache key: tracks:1')\n\t\t\texpect(error.name).toBe('LibraryValueNotFoundError')\n\t\t\texpect(error).toBeInstanceOf(Error)\n\t\t})\n\t})\n\n\tdescribe('concurrent access', () => {\n\t\tit('should handle concurrent requests for same value', async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst trackData = {\n\t\t\t\tid: 1,\n\t\t\t\tname: 'Test Track',\n\t\t\t\talbum: 'Test Album',\n\t\t\t\tartists: ['Test Artist'],\n\t\t\t\tuuid: 'track-uuid-1',\n\t\t\t\tyear: '2023',\n\t\t\t\tduration: 180,\n\t\t\t\tgenre: ['Rock'],\n\t\t\t\ttrackNo: 1,\n\t\t\t\ttrackOf: 10,\n\t\t\t\tdiscNo: 1,\n\t\t\t\tdiscOf: 1,\n\t\t\t\tfile: {} as File,\n\t\t\t\tscannedAt: 1_234_567_890,\n\t\t\t\tfileName: 'test-track.mp3',\n\t\t\t\tdirectory: 1,\n\t\t\t}\n\n\t\t\tawait db.add('tracks', trackData)\n\n\t\t\t// Make multiple concurrent requests\n\t\t\tconst promises = [\n\t\t\t\tgetLibraryValue('tracks', 1),\n\t\t\t\tgetLibraryValue('tracks', 1),\n\t\t\t\tgetLibraryValue('tracks', 1),\n\t\t\t]\n\n\t\t\tconst results = await Promise.all(promises)\n\n\t\t\t// All results should be identical\n\t\t\texpect(results[0]).toEqual(results[1])\n\t\t\texpect(results[1]).toEqual(results[2])\n\t\t\texpect(results[0]).toEqual({\n\t\t\t\t...trackData,\n\t\t\t\ttype: 'track',\n\t\t\t\tfavorite: false,\n\t\t\t})\n\t\t})\n\t})\n\n\tdescribe('error handling', () => {\n\t\tit('should handle LibraryValueNotFoundError correctly', async () => {\n\t\t\tawait expect(getLibraryValue('tracks', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t\tawait expect(getLibraryValue('albums', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t\tawait expect(getLibraryValue('artists', 999)).rejects.toThrow(LibraryValueNotFoundError)\n\t\t\tawait expect(getLibraryValue('playlists', 999)).rejects.toThrow(\n\t\t\t\tLibraryValueNotFoundError,\n\t\t\t)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/library/get/ids-queries.ts",
    "content": "import type { DatabaseChangeDetailsList } from '$lib/db/events.ts'\nimport type { DbChangeActions } from '$lib/db/query/base-query.svelte.ts'\nimport {\n\tcreatePageQuery,\n\ttype PageQueryOptions,\n\ttype PageQueryResult,\n\ttype QueryKey,\n} from '$lib/db/query/page-query.svelte.ts'\nimport type { LibraryStoreName } from '../types.ts'\nimport { preloadLibraryValue } from './value.ts'\n\nexport type { PageQueryResult } from '$lib/db/query/page-query.svelte.ts'\nexport type { QueryResult } from '$lib/db/query/query.ts'\n\nconst preloadLimit = 12\n\nconst preloadLibraryListValues = async <Store extends LibraryStoreName>(\n\tstoreName: Store,\n\tkeys: number[],\n) => {\n\tconst preload = Array.from({ length: Math.min(keys.length, preloadLimit) }, (_, index) => {\n\t\tconst id = keys[index]\n\t\tif (id) {\n\t\t\treturn preloadLibraryValue(storeName, id)\n\t\t}\n\n\t\treturn null\n\t})\n\tawait Promise.all(preload)\n}\n\nconst keysListDatabaseChangeHandler = <Store extends LibraryStoreName>(\n\tstoreName: Store,\n\tchanges: DatabaseChangeDetailsList,\n\t{ mutate, refetch }: DbChangeActions<number[]>,\n): void => {\n\tlet needRefetch = false\n\tfor (const change of changes) {\n\t\tif (change.storeName !== storeName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif (\n\t\t\t// We have no way of knowing where should the new item be inserted.\n\t\t\t// So we just refetch the whole list.\n\t\t\tchange.operation === 'add' ||\n\t\t\t// If playlist name changes, order might change as well.\n\t\t\t(storeName === 'playlists' && change.operation === 'update')\n\t\t) {\n\t\t\tneedRefetch = true\n\t\t\tbreak\n\t\t}\n\n\t\tif (change.operation === 'delete' && change.key !== undefined) {\n\t\t\tmutate((value) => {\n\t\t\t\tif (!value) {\n\t\t\t\t\treturn []\n\t\t\t\t}\n\n\t\t\t\tconst index = value.indexOf(change.key)\n\t\t\t\tif (index === -1) {\n\t\t\t\t\treturn value\n\t\t\t\t}\n\n\t\t\t\tvalue.splice(index, 1)\n\n\t\t\t\treturn value\n\t\t\t})\n\t\t}\n\t}\n\n\tif (needRefetch) {\n\t\trefetch()\n\t}\n}\n\nexport type LibraryItemKeysPageQueryOptions<K extends QueryKey> = Omit<\n\tPageQueryOptions<K, number[]>,\n\t'onDatabaseChange'\n>\n\nexport const createLibraryItemKeysPageQuery = <\n\tStore extends LibraryStoreName,\n\tconst K extends QueryKey,\n>(\n\tstoreName: Store,\n\toptions: LibraryItemKeysPageQueryOptions<K>,\n): Promise<PageQueryResult<number[]>> =>\n\tcreatePageQuery({\n\t\t...options,\n\t\tfetcher: async (key, signal) => {\n\t\t\tconst result = await options.fetcher(key, signal)\n\t\t\tawait preloadLibraryListValues(storeName, result)\n\n\t\t\treturn result\n\t\t},\n\t\tonDatabaseChange: keysListDatabaseChangeHandler.bind(null, storeName),\n\t})\n"
  },
  {
    "path": "src/lib/library/get/ids.ts",
    "content": "import type { IDBPIndex } from 'idb'\nimport { type AppDB, type AppIndexNames, getDatabase } from '$lib/db/database.ts'\nimport type { LibraryStoreName } from '../types.ts'\n\nexport type SortOrder = 'asc' | 'desc'\nexport type LibraryItemSortKey<Store extends LibraryStoreName> = Exclude<\n\tAppIndexNames<Store>,\n\tsymbol\n>\n\nexport interface GetLibraryItemIdsOptions<Store extends LibraryStoreName> {\n\tsort: LibraryItemSortKey<Store>\n\torder?: SortOrder\n\tsearchTerm?: string\n\tsearchFn?: (value: AppDB[Store]['value'], term: string) => boolean\n\tsignal?: AbortSignal\n}\n\ntype GetLibraryItemIdsIndex<Store extends LibraryStoreName> = IDBPIndex<\n\tAppDB,\n\t[Store],\n\tStore,\n\tkeyof AppDB[Store]['indexes'] & string\n>\n\nconst getLibraryItemIdsWithSearchSlow = async <Store extends LibraryStoreName>(\n\tstoreIndex: GetLibraryItemIdsIndex<Store>,\n\tsearchTerm: string,\n\tsearchFn: (value: AppDB[Store]['value'], term: string) => boolean,\n\tsignal: AbortSignal | undefined,\n) => {\n\tconst data: number[] = []\n\n\tfor await (const cursor of storeIndex.iterate()) {\n\t\tif (signal?.aborted) {\n\t\t\tbreak\n\t\t}\n\n\t\tif (searchFn(cursor.value, searchTerm)) {\n\t\t\tdata.push(cursor.primaryKey)\n\t\t}\n\t}\n\n\treturn data\n}\n\nexport const getLibraryItemIds = async <Store extends LibraryStoreName>(\n\tstore: Store,\n\toptions: GetLibraryItemIdsOptions<Store>,\n): Promise<number[]> => {\n\tconst db = await getDatabase()\n\tconst storeIndex = db.transaction(store).store.index(options.sort)\n\n\tconst { searchTerm, searchFn } = options\n\n\tlet data: number[]\n\tif (searchTerm && searchFn) {\n\t\tdata = await getLibraryItemIdsWithSearchSlow(\n\t\t\tstoreIndex,\n\t\t\tsearchTerm,\n\t\t\tsearchFn,\n\t\t\toptions.signal,\n\t\t)\n\t} else {\n\t\t// Fast path\n\t\tdata = await db.getAllKeysFromIndex(store, options.sort)\n\t}\n\n\tif (options.order === 'desc') {\n\t\tdata.reverse()\n\t}\n\n\treturn data\n}\n\nexport const dbGetAlbumTracksIdsByName = async (albumName: string): Promise<number[]> => {\n\tconst db = await getDatabase()\n\tconst tracksIds = await db.getAllKeysFromIndex(\n\t\t'tracks',\n\t\t'byAlbumSorted',\n\t\tIDBKeyRange.bound([albumName], [albumName, '\\uffff']),\n\t)\n\n\treturn tracksIds\n}\n\nexport const dbGetArtistTracksIdsByName = async (artistName: string): Promise<number[]> => {\n\tconst db = await getDatabase()\n\tconst tracksIds = await db.getAllKeysFromIndex(\n\t\t'tracks',\n\t\t'artists',\n\t\tIDBKeyRange.only(artistName),\n\t)\n\n\treturn tracksIds\n}\n"
  },
  {
    "path": "src/lib/library/get/value-queries.ts",
    "content": "import { createQuery, type QueryResult } from '$lib/db/query/query.ts'\nimport type { LibraryStoreName } from '../types.ts'\nimport { type GetLibraryValueResult, getLibraryValue, shouldRefetchLibraryValue } from './value.ts'\n\nexport type { AlbumData, ArtistData, PlaylistData, TrackData } from './value.ts'\n\nexport interface LibraryValueQueryOptions<AllowEmpty extends boolean = false> {\n\tallowEmpty?: AllowEmpty\n}\n\nconst defineQuery =\n\t<Store extends LibraryStoreName>(storeName: Store) =>\n\t<AllowEmpty extends boolean = false>(\n\t\tidGetter: () => number,\n\t\toptions: LibraryValueQueryOptions<AllowEmpty> = {},\n\t): QueryResult<GetLibraryValueResult<Store, AllowEmpty>> =>\n\t\tcreateQuery({\n\t\t\tkey: idGetter,\n\t\t\tfetcher: (id) => getLibraryValue(storeName, id, options.allowEmpty),\n\t\t\tonDatabaseChange: (changes, { refetch }) => {\n\t\t\t\tif (shouldRefetchLibraryValue(storeName, idGetter(), changes)) {\n\t\t\t\t\tvoid refetch()\n\t\t\t\t}\n\t\t\t},\n\t\t})\n\ntype LibraryItemQuery<Store extends LibraryStoreName> = ReturnType<typeof defineQuery<Store>>\n\nexport const createTrackQuery: LibraryItemQuery<'tracks'> = /* @__PURE__ */ defineQuery('tracks')\nexport const createAlbumQuery: LibraryItemQuery<'albums'> = /* @__PURE__ */ defineQuery('albums')\nexport const createArtistQuery: LibraryItemQuery<'artists'> = /* @__PURE__ */ defineQuery('artists')\nexport const createPlaylistQuery: LibraryItemQuery<'playlists'> =\n\t/* @__PURE__ */ defineQuery('playlists')\n"
  },
  {
    "path": "src/lib/library/get/value.ts",
    "content": "import { WeakLRUCache } from 'weak-lru-cache'\nimport { type DbKey, getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, onDatabaseChange } from '$lib/db/events.ts'\nimport type { Album, Artist, Playlist, Track } from '$lib/library/types.ts'\nimport { FAVORITE_PLAYLIST_ID, FAVORITE_PLAYLIST_UUID, type LibraryStoreName } from '../types.ts'\n\ntype CacheKey<Store extends LibraryStoreName> = `${Store}:${string}`\n\nconst getCacheKey = <Store extends LibraryStoreName>(\n\tstoreName: Store,\n\tkey: DbKey<Store>,\n): CacheKey<Store> => `${storeName}:${key}`\n\ninterface QueryConfig<Result> {\n\tfetch: (id: number) => Promise<Result | undefined>\n\tshouldRefetch: (\n\t\titemId: number | undefined,\n\t\tchanges: readonly DatabaseChangeDetails[],\n\t) => boolean\n}\n\nconst defaultRefreshOnDatabaseChanges = (\n\tstoreName: LibraryStoreName,\n\titemId: number | undefined,\n\tchanges: readonly DatabaseChangeDetails[],\n) => {\n\tfor (const change of changes) {\n\t\tif (change.storeName === storeName) {\n\t\t\tif (itemId === undefined) {\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\tif (change.key === itemId) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nexport interface TrackData extends Track {\n\ttype: 'track'\n\tfavorite: boolean\n}\n\nconst trackConfig: QueryConfig<TrackData> = {\n\tfetch: async (id) => {\n\t\tconst db = await getDatabase()\n\t\tconst tx = db.transaction(['tracks', 'playlistEntries'], 'readonly')\n\n\t\tconst [item, favorite] = await Promise.all([\n\t\t\ttx.objectStore('tracks').get(id),\n\t\t\ttx\n\t\t\t\t.objectStore('playlistEntries')\n\t\t\t\t.index('playlistTrack')\n\t\t\t\t.get([FAVORITE_PLAYLIST_ID, id]),\n\t\t])\n\n\t\tif (!item) {\n\t\t\treturn undefined\n\t\t}\n\n\t\treturn {\n\t\t\t...item,\n\t\t\ttype: 'track',\n\t\t\tfavorite: !!favorite,\n\t\t} as TrackData\n\t},\n\tshouldRefetch: (itemId, changes) => {\n\t\tfor (const change of changes) {\n\t\t\tif (change.storeName === 'playlistEntries') {\n\t\t\t\tconst playlistEntry = change.value\n\n\t\t\t\tif (\n\t\t\t\t\tplaylistEntry.playlistId === FAVORITE_PLAYLIST_ID &&\n\t\t\t\t\titemId === playlistEntry.trackId\n\t\t\t\t) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (change.storeName === 'tracks' && change.key === itemId) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\n\t\treturn false\n\t},\n}\n\nconst dbGetValue = async <Store extends LibraryStoreName, const T extends string>(\n\tstoreName: Store,\n\ttype: T,\n\tid: number,\n) => {\n\tconst db = await getDatabase()\n\tconst value = await db.get(storeName, id)\n\tif (!value) {\n\t\treturn undefined\n\t}\n\n\treturn {\n\t\t...value,\n\t\ttype,\n\t}\n}\n\nexport interface AlbumData extends Album {\n\ttype: 'album'\n}\n\nconst albumConfig: QueryConfig<AlbumData> = {\n\tfetch: (id) => dbGetValue('albums', 'album', id),\n\tshouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'albums'),\n}\nexport interface ArtistData extends Artist {\n\ttype: 'artist'\n}\n\nconst artistConfig: QueryConfig<ArtistData> = {\n\tfetch: (id) => dbGetValue('artists', 'artist', id),\n\tshouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'artists'),\n}\n\nexport interface PlaylistData extends Playlist {\n\ttype: 'playlist'\n}\n\nconst playlistsConfig: QueryConfig<PlaylistData> = {\n\tfetch: (id) => {\n\t\tif (id === FAVORITE_PLAYLIST_ID) {\n\t\t\tconst favoritePlaylist: PlaylistData = {\n\t\t\t\ttype: 'playlist',\n\t\t\t\tid: FAVORITE_PLAYLIST_ID,\n\t\t\t\tuuid: FAVORITE_PLAYLIST_UUID,\n\t\t\t\tname: m.favorites(),\n\t\t\t\tdescription: '',\n\t\t\t\tcreatedAt: 0,\n\t\t\t}\n\n\t\t\treturn Promise.resolve(favoritePlaylist)\n\t\t}\n\n\t\treturn dbGetValue('playlists', 'playlist', id)\n\t},\n\tshouldRefetch: defaultRefreshOnDatabaseChanges.bind(null, 'playlists'),\n}\n\ninterface LibraryValueMap {\n\ttracks: TrackData\n\talbums: AlbumData\n\tartists: ArtistData\n\tplaylists: PlaylistData\n}\n\ntype LibraryValue<Store extends LibraryStoreName = LibraryStoreName> = LibraryValueMap[Store]\n\ntype LibraryConfigMap = {\n\t[Store in LibraryStoreName]: QueryConfig<LibraryValue<Store>>\n}\n\nconst libraryConfigMap = {\n\ttracks: trackConfig,\n\talbums: albumConfig,\n\tartists: artistConfig,\n\tplaylists: playlistsConfig,\n} satisfies LibraryConfigMap\n\ntype LibraryCachedValue<Store extends LibraryStoreName = LibraryStoreName> =\n\t| LibraryValue<Store>\n\t| Promise<LibraryValue<Store> | undefined>\n\nclass LibraryValueCache {\n\t#cache = new WeakLRUCache<CacheKey<LibraryStoreName>, LibraryCachedValue<LibraryStoreName>>({\n\t\tcacheSize: 10_000,\n\t})\n\n\tget<Store extends LibraryStoreName>(key: CacheKey<Store>) {\n\t\treturn this.#cache.getValue(key) as LibraryCachedValue<Store> | undefined\n\t}\n\n\tset<Store extends LibraryStoreName>(\n\t\tkey: CacheKey<Store>,\n\t\tvalue: LibraryCachedValue<Store> | undefined,\n\t) {\n\t\tif (value) {\n\t\t\tthis.#cache.setValue(key, value)\n\t\t} else {\n\t\t\tthis.delete(key)\n\t\t}\n\t}\n\n\tdelete<Store extends LibraryStoreName>(key: CacheKey<Store>) {\n\t\tthis.#cache.delete(key)\n\t}\n\n\tclear() {\n\t\tthis.#cache.clear()\n\t}\n}\n\n// Fast in memory cache for `items`, so we do not need to\n// call indexed db for every access.\n// IMPORTANT. Only store whole library items in here.\nconst valueCache = new LibraryValueCache()\n\nif (import.meta.env.DEV) {\n\t// @ts-expect-error used for debugging\n\tglobalThis.libraryValueCache = valueCache\n}\n\nif (!import.meta.env.SSR) {\n\tonDatabaseChange((changes) => {\n\t\tfor (const change of changes) {\n\t\t\tconst { storeName } = change\n\n\t\t\tif (\n\t\t\t\tstoreName === 'tracks' ||\n\t\t\t\tstoreName === 'albums' ||\n\t\t\t\tstoreName === 'artists' ||\n\t\t\t\tstoreName === 'playlists'\n\t\t\t) {\n\t\t\t\tif (change.operation === 'delete' || change.operation === 'update') {\n\t\t\t\t\tconst cacheKey = getCacheKey(storeName, change.key)\n\t\t\t\t\tvalueCache.delete(cacheKey)\n\t\t\t\t}\n\t\t\t} else if (storeName === 'playlistEntries') {\n\t\t\t\tconst playlistEntry = change.value\n\n\t\t\t\tif (playlistEntry.playlistId === FAVORITE_PLAYLIST_ID) {\n\t\t\t\t\tconst cacheKey = getCacheKey('tracks', playlistEntry.trackId)\n\t\t\t\t\tvalueCache.delete(cacheKey)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nexport class LibraryValueNotFoundError extends Error {\n\tconstructor(cacheKey: CacheKey<LibraryStoreName>) {\n\t\tsuper(`Value not found. Cache key: ${cacheKey}`)\n\t\tthis.name = 'LibraryValueNotFoundError'\n\t}\n}\n\nconst assertsValue = <T, AllowEmpty extends boolean = false>(\n\tvalue: T,\n\tallowEmpty: AllowEmpty | undefined,\n\tcacheKey: CacheKey<LibraryStoreName>,\n) => {\n\tif (!(value || allowEmpty)) {\n\t\tthrow new LibraryValueNotFoundError(cacheKey)\n\t}\n\n\treturn value\n}\n\nconst getCachedOrFetchValue = <Store extends LibraryStoreName>(\n\tkey: CacheKey<Store>,\n\tfetchValue: () => Promise<GetLibraryValueResult<Store> | undefined>,\n): LibraryValue<Store> | Promise<LibraryValue<Store> | undefined> => {\n\tconst cachedValue = valueCache.get(key)\n\tif (cachedValue) {\n\t\treturn cachedValue\n\t}\n\n\tconst promise = fetchValue()\n\t\t.then((value) => {\n\t\t\tvalueCache.set(key, value)\n\n\t\t\treturn value\n\t\t})\n\t\t.catch((error) => {\n\t\t\tvalueCache.delete(key)\n\t\t\tthrow error\n\t\t})\n\n\tvalueCache.set(key, promise)\n\n\treturn promise\n}\n\nexport type GetLibraryValueResult<\n\tStore extends LibraryStoreName,\n\tAllowEmpty extends boolean = false,\n> = AllowEmpty extends true ? LibraryValue<Store> | undefined : LibraryValue<Store>\n\n/** @public */\nexport const getLibraryValue = <Store extends LibraryStoreName, AllowEmpty extends boolean = false>(\n\tstoreName: Store,\n\tid: number,\n\tallowEmpty?: AllowEmpty,\n): Promise<GetLibraryValueResult<Store, AllowEmpty>> | GetLibraryValueResult<Store, AllowEmpty> => {\n\tconst key = getCacheKey(storeName, id)\n\tconst result = getCachedOrFetchValue(key, () => {\n\t\tconst config: LibraryConfigMap[Store] = libraryConfigMap[storeName]\n\n\t\treturn config.fetch(id)\n\t})\n\n\tif (result instanceof Promise) {\n\t\tconst promiseResult = result.then((value) =>\n\t\t\tassertsValue(value, allowEmpty, key),\n\t\t) as Promise<GetLibraryValueResult<Store, AllowEmpty>>\n\n\t\treturn promiseResult\n\t}\n\n\treturn assertsValue(result, allowEmpty, key)\n}\n\n/** @public */\nexport const preloadLibraryValue = async (\n\tstoreName: LibraryStoreName,\n\tid: number,\n): Promise<void> => {\n\ttry {\n\t\t// this will fetch data and store it inside cache\n\t\tawait getLibraryValue(storeName, id)\n\t} catch {\n\t\t// Ignore\n\t}\n}\n\nexport const shouldRefetchLibraryValue = (\n\tstoreName: LibraryStoreName,\n\tid: number | undefined,\n\tchanges: readonly DatabaseChangeDetails[],\n): boolean => {\n\tconst config = libraryConfigMap[storeName]\n\n\treturn config.shouldRefetch(id, changes)\n}\n\n/** @private - Used for testing only */\nexport const clearLibraryValueCache = () => {\n\tvalueCache.clear()\n}\n"
  },
  {
    "path": "src/lib/library/play-history-actions.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimport { createUIAction } from '$lib/helpers/ui-action.ts'\nimport type { PlayHistoryEntry } from './types.ts'\n\nconst PLAY_HISTORY_LIMIT = 100\n\nconst notifyPlayHistoryChange = () => {\n\tdispatchDatabaseChangedEvent({\n\t\tstoreName: 'playHistory',\n\t})\n}\n\nexport const dbAddToPlayHistory = async (trackId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['tracks', 'playHistory'], 'readwrite')\n\tconst tracksStore = tx.objectStore('tracks')\n\tconst store = tx.objectStore('playHistory')\n\n\t// Don't add orphaned history records for tracks that are no longer in library.\n\tconst trackExists = (await tracksStore.count(trackId)) > 0\n\tif (!trackExists) {\n\t\tawait tx.done\n\t\treturn\n\t}\n\n\tconst newEntry: Omit<PlayHistoryEntry, 'id'> = {\n\t\ttrackId,\n\t\tplayedAt: Date.now(),\n\t}\n\n\tconst existingKey = await store.index('trackId').getKey(trackId)\n\tif (existingKey === undefined) {\n\t\tawait store.add(newEntry as PlayHistoryEntry)\n\t} else {\n\t\tawait store.put({\n\t\t\t...newEntry,\n\t\t\tid: existingKey,\n\t\t})\n\t}\n\n\t// Keep only the most recent PLAY_HISTORY_LIMIT entries.\n\t// Start at newest and jump over the records we keep, then delete the tail.\n\tlet cursor = await store.index('playedAt').openCursor(null, 'prev')\n\tif (cursor !== null) {\n\t\tcursor = await cursor.advance(PLAY_HISTORY_LIMIT)\n\t}\n\n\twhile (cursor !== null) {\n\t\tawait cursor.delete()\n\t\tcursor = await cursor.continue()\n\t}\n\n\tawait tx.done\n\n\tnotifyPlayHistoryChange()\n}\n\nexport const dbRemoveFromPlayHistory = async (trackId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction('playHistory', 'readwrite')\n\tconst store = tx.objectStore('playHistory')\n\n\tconst historyIds = await store.index('trackId').getAllKeys(trackId)\n\tawait Promise.all(historyIds.map((id) => store.delete(id)))\n\tawait tx.done\n\n\tnotifyPlayHistoryChange()\n}\n\nconst dbClearPlayHistory = async (): Promise<void> => {\n\tconst db = await getDatabase()\n\tawait db.clear('playHistory')\n\n\tnotifyPlayHistoryChange()\n}\n\nexport const clearPlayHistory = createUIAction(false, dbClearPlayHistory)\n"
  },
  {
    "path": "src/lib/library/playlists-actions.ts",
    "content": "import type { IDBPObjectStore } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimport { createUIAction } from '$lib/helpers/ui-action.ts'\nimport { truncate } from '$lib/helpers/utils/text.ts'\nimport type { Playlist, PlaylistEntry } from '$lib/library/types.ts'\nimport { FAVORITE_PLAYLIST_ID } from './types.ts'\n\nexport { FAVORITE_PLAYLIST_ID } from './types.ts'\n\nexport const dbCreatePlaylist = async (\n\tname: string,\n\tdescription: string,\n\tcreatedAt = Date.now(),\n): Promise<number> => {\n\tconst db = await getDatabase()\n\n\tconst newPlaylist: Omit<Playlist, 'id'> = {\n\t\tname,\n\t\tdescription,\n\t\tuuid: crypto.randomUUID(),\n\t\tcreatedAt,\n\t}\n\n\tconst id = await db.add('playlists', newPlaylist as Playlist)\n\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'add',\n\t\tstoreName: 'playlists',\n\t\tkey: id,\n\t})\n\n\treturn id\n}\n\nexport const createPlaylist = async (name: string, description: string): Promise<void> => {\n\ttry {\n\t\tawait dbCreatePlaylist(name, description)\n\n\t\tsnackbar(\n\t\t\tm.libraryPlaylistCreated({\n\t\t\t\tplaylistName: truncate(name, 20),\n\t\t\t}),\n\t\t)\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nexport interface UpdatePlaylistOptions {\n\tid: number\n\tname: string\n\tdescription: string\n}\n\nconst dbUpdatePlaylist = async (options: UpdatePlaylistOptions): Promise<void> => {\n\tconst db = await getDatabase()\n\n\tconst id = options.id\n\n\tconst tx = db.transaction('playlists', 'readwrite')\n\tconst existingPlaylist = await tx.store.get(id)\n\n\tinvariant(existingPlaylist, 'Playlist not found')\n\n\tconst updatedPlaylist: Playlist = {\n\t\t...existingPlaylist,\n\t\tname: options.name,\n\t\tdescription: options.description,\n\t}\n\n\tawait tx.store.put(updatedPlaylist)\n\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'update',\n\t\tstoreName: 'playlists',\n\t\tkey: id,\n\t})\n}\n\nexport const updatePlaylist = async (options: UpdatePlaylistOptions): Promise<boolean> => {\n\ttry {\n\t\tawait dbUpdatePlaylist(options)\n\n\t\tsnackbar({\n\t\t\tid: `playlist-updated-${options.id}`,\n\t\t\tmessage: m.libraryPlaylistUpdated(truncate(options.name, 20)),\n\t\t})\n\n\t\treturn true\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\n\t\treturn false\n\t}\n}\n\nexport const dbRemovePlaylist = async (playlistId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['playlists', 'playlistEntries'], 'readwrite')\n\tconst entriesStore = tx.objectStore('playlistEntries')\n\n\tconst entriesIds = await entriesStore\n\t\t.index('playlistTrack')\n\t\t.getAllKeys(IDBKeyRange.bound([playlistId], [playlistId + 1], false, true))\n\n\tawait Promise.all([\n\t\t...entriesIds.map((id) => entriesStore.delete(id)),\n\t\ttx.objectStore('playlists').delete(playlistId),\n\t\ttx.done,\n\t])\n\n\t// We are not notifying about individual tracks removals\n\t// because we are removing the whole playlist\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'delete',\n\t\tstoreName: 'playlists',\n\t\tkey: playlistId,\n\t})\n}\n\nexport type DbPlaylistEntriesStore = IDBPObjectStore<\n\tAppDB,\n\t['playlistEntries'],\n\t'playlistEntries',\n\t'readwrite'\n>\n\nexport const getPlaylistEntriesDatabaseStore = async (): Promise<DbPlaylistEntriesStore> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction('playlistEntries', 'readwrite')\n\tconst store = tx.objectStore('playlistEntries')\n\n\treturn store\n}\n\nexport interface AddTracksToPlaylistOptions {\n\tplaylistIds: readonly number[]\n\ttrackIds: readonly number[]\n}\n\nexport const dbAddTracksToPlaylistsWithTx = (\n\tstore: DbPlaylistEntriesStore,\n\toptions: AddTracksToPlaylistOptions,\n) => {\n\tconst promises = options.trackIds.flatMap((trackId) =>\n\t\toptions.playlistIds.map(async (playlistId) => {\n\t\t\tconst playlistEntry: Omit<PlaylistEntry, 'id'> = {\n\t\t\t\tplaylistId,\n\t\t\t\ttrackId,\n\t\t\t\taddedAt: Date.now(),\n\t\t\t}\n\n\t\t\tconst playlistEntryId = await store.add(playlistEntry as PlaylistEntry)\n\n\t\t\tconst change: DatabaseChangeDetails = {\n\t\t\t\tstoreName: 'playlistEntries',\n\t\t\t\tkey: playlistEntryId,\n\t\t\t\toperation: 'add',\n\t\t\t\tvalue: {\n\t\t\t\t\t...playlistEntry,\n\t\t\t\t\tid: playlistEntryId,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn change\n\t\t}),\n\t)\n\n\treturn Promise.all(promises)\n}\n\ninterface RemoveTracksFromPlaylistOptions {\n\tplaylistIds: readonly number[]\n\ttrackIds: readonly number[]\n}\n\nexport const dbRemoveTracksFromPlaylistsWithTx = async (\n\tstore: DbPlaylistEntriesStore,\n\toptions: RemoveTracksFromPlaylistOptions,\n) => {\n\tconst { playlistIds, trackIds } = options\n\n\tconst trackIdIndex = store.index('trackId')\n\tconst changes: DatabaseChangeDetails[] = []\n\n\tfor (const trackId of trackIds) {\n\t\tfor await (const cursor of trackIdIndex.iterate(trackId)) {\n\t\t\tif (playlistIds.includes(cursor.value.playlistId)) {\n\t\t\t\tawait cursor.delete()\n\t\t\t\tchanges.push({\n\t\t\t\t\tstoreName: 'playlistEntries',\n\t\t\t\t\toperation: 'delete',\n\t\t\t\t\tkey: cursor.primaryKey,\n\t\t\t\t\tvalue: cursor.value,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn changes\n}\n\ninterface BatchModifyPlaylistSelectionOptions {\n\ttrackIds: readonly number[]\n\tplaylistsIdsAddTo: readonly number[]\n\tplaylistsIdsRemoveFrom: readonly number[]\n}\n\nexport const dbBatchModifyPlaylistsSelection = async (\n\toptions: BatchModifyPlaylistSelectionOptions,\n): Promise<boolean> => {\n\tconst store = await getPlaylistEntriesDatabaseStore()\n\tconst { trackIds, playlistsIdsAddTo, playlistsIdsRemoveFrom } = options\n\n\tconst allChanges: DatabaseChangeDetails[] = []\n\tif (playlistsIdsRemoveFrom.length > 0) {\n\t\tconst changes = await dbRemoveTracksFromPlaylistsWithTx(store, {\n\t\t\tplaylistIds: playlistsIdsRemoveFrom,\n\t\t\ttrackIds,\n\t\t})\n\t\tallChanges.push(...changes)\n\t}\n\n\tif (playlistsIdsAddTo.length > 0) {\n\t\tconst changes = await dbAddTracksToPlaylistsWithTx(store, {\n\t\t\tplaylistIds: playlistsIdsAddTo,\n\t\t\ttrackIds,\n\t\t})\n\t\tallChanges.push(...changes)\n\t}\n\n\tdispatchDatabaseChangedEvent(allChanges)\n\n\treturn allChanges.length > 0\n}\n\nconst dbRemoveTrackEntryFromPlaylist = async (playlistEntryId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\n\tconst entry = await db.get('playlistEntries', playlistEntryId)\n\tinvariant(entry)\n\n\tawait db.delete('playlistEntries', entry.id)\n\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'delete',\n\t\tstoreName: 'playlistEntries',\n\t\tkey: entry.id,\n\t\tvalue: entry,\n\t})\n}\n\nexport const removeTrackEntryFromPlaylist = createUIAction(\n\tm.libraryTrackRemovedFromPlaylist(),\n\t(playlistEntryId: number) => dbRemoveTrackEntryFromPlaylist(playlistEntryId),\n)\n\nconst dbAddTrackToFavorites = async (trackId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\n\t// Check if already exists to prevent duplicates\n\tconst existing = await db.getFromIndex('playlistEntries', 'playlistTrack', [\n\t\tFAVORITE_PLAYLIST_ID,\n\t\ttrackId,\n\t])\n\n\tif (existing) {\n\t\treturn\n\t}\n\n\tconst playlistEntry: Omit<PlaylistEntry, 'id'> = {\n\t\tplaylistId: FAVORITE_PLAYLIST_ID,\n\t\ttrackId,\n\t\taddedAt: Date.now(),\n\t}\n\n\tconst key = await db.add('playlistEntries', playlistEntry as PlaylistEntry)\n\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'add',\n\t\tstoreName: 'playlistEntries',\n\t\tkey,\n\t\tvalue: {\n\t\t\t...playlistEntry,\n\t\t\tid: key,\n\t\t},\n\t})\n}\n\nconst dbRemoveTrackFromFavorites = async (trackId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\n\tconst entry = await db.getFromIndex('playlistEntries', 'playlistTrack', [\n\t\tFAVORITE_PLAYLIST_ID,\n\t\ttrackId,\n\t])\n\tinvariant(entry)\n\n\tawait db.delete('playlistEntries', entry.id)\n\n\tdispatchDatabaseChangedEvent({\n\t\toperation: 'delete',\n\t\tstoreName: 'playlistEntries',\n\t\tkey: entry.id,\n\t\tvalue: entry,\n\t})\n}\n\nexport const toggleFavoriteTrack = async (\n\tshouldBeRemoved: boolean,\n\ttrackId: number,\n): Promise<boolean> => {\n\ttry {\n\t\tif (shouldBeRemoved) {\n\t\t\tawait dbRemoveTrackFromFavorites(trackId)\n\t\t} else {\n\t\t\tawait dbAddTrackToFavorites(trackId)\n\t\t}\n\n\t\treturn true\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/remove.ts",
    "content": "import type { IDBPTransaction } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimport type { Track } from './types.ts'\n\ntype TrackOperationsTransaction = IDBPTransaction<\n\tAppDB,\n\t('tracks' | 'albums' | 'artists' | 'playlistEntries' | 'playHistory')[],\n\t'readwrite'\n>\n\nconst dedupe = <T>(values: readonly T[]): readonly T[] => {\n\tif (values.length < 2) {\n\t\treturn values\n\t}\n\n\treturn [...new Set(values)]\n}\n\nconst dbRemoveTracksFromPlayHistoryWithTx = async (\n\ttx: TrackOperationsTransaction,\n\ttrackIds: readonly number[],\n): Promise<DatabaseChangeDetails | undefined> => {\n\tconst store = tx.objectStore('playHistory')\n\tconst trackIdIndex = store.index('trackId')\n\n\tlet removedAny = false\n\tfor (const trackId of trackIds) {\n\t\tconst historyId = await trackIdIndex.getKey(trackId)\n\t\tif (historyId === undefined) {\n\t\t\tcontinue\n\t\t}\n\n\t\tawait store.delete(historyId)\n\t\tremovedAny = true\n\t}\n\n\tif (!removedAny) {\n\t\treturn\n\t}\n\n\treturn { storeName: 'playHistory' }\n}\n\nconst dbRemoveTracksFromAllPlaylistsWithTx = async (\n\ttx: TrackOperationsTransaction,\n\ttrackIds: readonly number[],\n) => {\n\tconst store = tx.objectStore('playlistEntries')\n\tconst trackIdIndex = store.index('trackId')\n\n\tconst changes: DatabaseChangeDetails[] = []\n\tfor (const trackId of trackIds) {\n\t\tconst entries = await trackIdIndex.getAll(trackId)\n\t\tawait Promise.all(entries.map((entry) => store.delete(entry.id)))\n\n\t\tchanges.push(\n\t\t\t...entries.map(\n\t\t\t\t(entry): DatabaseChangeDetails => ({\n\t\t\t\t\toperation: 'delete',\n\t\t\t\t\tstoreName: 'playlistEntries',\n\t\t\t\t\tkey: entry.id,\n\t\t\t\t\tvalue: entry,\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\t}\n\n\treturn changes\n}\n\nconst dbRemoveUnusedAlbumsWithTx = async (\n\ttx: TrackOperationsTransaction,\n\talbumNames: readonly Track['album'][],\n) => {\n\tconst tracksByAlbum = tx.objectStore('tracks').index('album')\n\tconst albumsStore = tx.objectStore('albums')\n\n\tconst changes: DatabaseChangeDetails[] = []\n\tfor (const albumName of dedupe(albumNames)) {\n\t\tconst albumNameKey = IDBKeyRange.only(albumName)\n\t\tconst tracksWithAlbumCount = await tracksByAlbum.count(albumNameKey)\n\t\tif (tracksWithAlbumCount > 0) {\n\t\t\tcontinue\n\t\t}\n\n\t\tconst album = await albumsStore.index('name').get(albumNameKey)\n\t\tif (!album) {\n\t\t\tcontinue\n\t\t}\n\n\t\tawait albumsStore.delete(album.id)\n\t\tchanges.push({\n\t\t\tstoreName: 'albums',\n\t\t\tkey: album.id,\n\t\t\toperation: 'delete',\n\t\t})\n\t}\n\n\treturn changes\n}\n\nconst dbRemoveUnusedArtistsWithTx = async (\n\ttx: TrackOperationsTransaction,\n\tartistNames: readonly string[],\n) => {\n\tconst tracksByArtist = tx.objectStore('tracks').index('artists')\n\tconst artistsStore = tx.objectStore('artists')\n\n\tconst changes: DatabaseChangeDetails[] = []\n\tfor (const artistName of dedupe(artistNames)) {\n\t\tconst artistNameKey = IDBKeyRange.only(artistName)\n\t\tconst tracksWithArtistCount = await tracksByArtist.count(artistNameKey)\n\t\tif (tracksWithArtistCount > 0) {\n\t\t\tcontinue\n\t\t}\n\n\t\tconst artist = await artistsStore.index('name').get(artistNameKey)\n\t\tif (!artist) {\n\t\t\tcontinue\n\t\t}\n\n\t\tawait artistsStore.delete(artist.id)\n\t\tchanges.push({\n\t\t\tstoreName: 'artists',\n\t\t\tkey: artist.id,\n\t\t\toperation: 'delete',\n\t\t})\n\t}\n\n\treturn changes\n}\n\nexport const dbRemoveTracks = async (trackIds: readonly number[]): Promise<void> => {\n\tif (trackIds.length === 0) {\n\t\treturn\n\t}\n\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(\n\t\t['tracks', 'albums', 'artists', 'playlistEntries', 'playHistory'],\n\t\t'readwrite',\n\t)\n\n\tconst tracksStore = tx.objectStore('tracks')\n\tconst existingTracks = (\n\t\tawait Promise.all(dedupe(trackIds).map((trackId) => tracksStore.get(trackId)))\n\t).filter((track) => track !== undefined)\n\n\tif (existingTracks.length === 0) {\n\t\tawait tx.done\n\t\treturn\n\t}\n\n\tconst existingTrackIds = await Promise.all(\n\t\texistingTracks.map((track) => tracksStore.delete(track.id).then(() => track.id)),\n\t)\n\n\tconst [albumChanges, playlistChanges, historyChange, artistChanges] = await Promise.all([\n\t\tdbRemoveUnusedAlbumsWithTx(\n\t\t\ttx,\n\t\t\texistingTracks.map((track) => track.album),\n\t\t),\n\t\tdbRemoveTracksFromAllPlaylistsWithTx(tx, existingTrackIds),\n\t\tdbRemoveTracksFromPlayHistoryWithTx(tx, existingTrackIds),\n\t\tdbRemoveUnusedArtistsWithTx(\n\t\t\ttx,\n\t\t\texistingTracks.flatMap((track) => track.artists),\n\t\t),\n\t])\n\n\tconst changes = [\n\t\t...existingTrackIds.map(\n\t\t\t(trackId): DatabaseChangeDetails => ({\n\t\t\t\tstoreName: 'tracks',\n\t\t\t\toperation: 'delete',\n\t\t\t\tkey: trackId,\n\t\t\t}),\n\t\t),\n\t\thistoryChange,\n\t\t...albumChanges,\n\t\t...artistChanges,\n\t\t...playlistChanges,\n\t].filter((change) => change !== undefined)\n\n\tawait tx.done\n\n\tif (changes.length > 0) {\n\t\tdispatchDatabaseChangedEvent(changes)\n\t}\n}\n\nexport const dbRemoveAlbum = async (albumId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['albums', 'tracks'], 'readonly')\n\tconst album = await tx.objectStore('albums').get(albumId)\n\tif (!album) {\n\t\tawait tx.done\n\t\treturn\n\t}\n\n\tconst tracksIds = await tx.objectStore('tracks').index('album').getAllKeys(album.name)\n\tawait tx.done\n\n\t// If no tracks references it, it will be deleted automatically\n\tawait dbRemoveTracks(tracksIds)\n}\n\nexport const dbRemoveArtist = async (artistId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['artists', 'tracks'], 'readonly')\n\tconst artist = await tx.objectStore('artists').get(artistId)\n\tif (!artist) {\n\t\tawait tx.done\n\t\treturn\n\t}\n\n\t// Artists is an array, we want to remove all tracks that reference this artist, artist can have other names as well\n\tconst tracksIds = await tx\n\t\t.objectStore('tracks')\n\t\t.index('artists')\n\t\t.getAllKeys(IDBKeyRange.only(artist.name))\n\n\tawait tx.done\n\n\t// If no tracks references it, it will be deleted automatically\n\tawait dbRemoveTracks(tracksIds)\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/directories.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimport { lockDatabase } from '$lib/db/lock-database.ts'\nimport { dbRemoveTracks } from '$lib/library/remove.ts'\nimport type { Directory } from '$lib/library/types.ts'\nimport { scanTracks } from './scan-tracks.ts'\n\nexport interface DirectoryStatus {\n\tstatus: 'child' | 'existing' | 'parent'\n\texistingDir: Directory\n\tnewDirHandle: FileSystemDirectoryHandle\n}\n\nexport const checkNewDirectoryStatus = async (\n\texistingDir: Directory,\n\tnewDirHandle: FileSystemDirectoryHandle,\n): Promise<DirectoryStatus | undefined> => {\n\tconst paths = await existingDir.handle.resolve(newDirHandle)\n\n\tlet status: 'child' | 'existing' | 'parent' | undefined\n\tif (paths) {\n\t\tstatus = paths.length === 0 ? 'existing' : 'child'\n\t} else {\n\t\tconst parent = await newDirHandle.resolve(existingDir.handle)\n\n\t\tif (parent) {\n\t\t\tstatus = 'parent'\n\t\t}\n\t}\n\n\tif (status) {\n\t\treturn {\n\t\t\tstatus,\n\t\t\texistingDir,\n\t\t\tnewDirHandle,\n\t\t}\n\t}\n\n\treturn undefined\n}\n\nconst dbImportNewDirectory = async (dirHandle: FileSystemDirectoryHandle): Promise<void> => {\n\tconst db = await getDatabase()\n\tconst id = await db.add('directories', {\n\t\thandle: dirHandle,\n\t} as Directory)\n\n\tdispatchDatabaseChangedEvent({\n\t\tkey: id,\n\t\tstoreName: 'directories',\n\t\toperation: 'add',\n\t})\n\n\tawait scanTracks({\n\t\taction: 'directory-add',\n\t\tdirId: id,\n\t\tdirHandle,\n\t})\n}\n\nexport const importNewDirectory = async (handle: FileSystemDirectoryHandle): Promise<void> => {\n\ttry {\n\t\tawait lockDatabase(() => dbImportNewDirectory(handle))\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nexport const rescanDirectory = async (\n\tdirId: number,\n\tdirHandle: FileSystemDirectoryHandle,\n): Promise<void> => {\n\tlet permission = await dirHandle.queryPermission()\n\tif (permission === 'prompt') {\n\t\tpermission = await dirHandle.requestPermission()\n\t}\n\n\tif (permission !== 'granted') {\n\t\tsnackbar(m.settingsGrantDirectoryAccess())\n\n\t\treturn\n\t}\n\n\ttry {\n\t\tawait lockDatabase(() =>\n\t\t\tscanTracks({\n\t\t\t\taction: 'directory-rescan',\n\t\t\t\tdirId,\n\t\t\t\tdirHandle,\n\t\t\t}),\n\t\t)\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nconst dbReplaceDirectories = async (\n\tparentDirHandle: FileSystemDirectoryHandle,\n\tdirectoriesIds: readonly number[],\n): Promise<void> => {\n\tconst dirIds = [...directoriesIds]\n\tconst directoryId = dirIds.pop()\n\t// We pick last id and make it the parents new id.\n\tinvariant(directoryId)\n\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['directories', 'tracks'], 'readwrite')\n\n\tconst newDir: Directory = {\n\t\tid: directoryId,\n\t\thandle: parentDirHandle,\n\t}\n\n\tconst replaceHandlePromise = tx\n\t\t.objectStore('directories')\n\t\t.put(newDir)\n\t\t.then(\n\t\t\t(): DatabaseChangeDetails => ({\n\t\t\t\tkey: directoryId,\n\t\t\t\tstoreName: 'directories',\n\t\t\t\toperation: 'update',\n\t\t\t}),\n\t\t)\n\n\tconst promises = dirIds.map(async (existingDirId): Promise<DatabaseChangeDetails[]> => {\n\t\t// Update all tracks to point to the new directory.\n\t\tconst updatedTracksPromise = tx\n\t\t\t.objectStore('tracks')\n\t\t\t.index('directory')\n\t\t\t.openCursor(existingDirId)\n\t\t\t.then(async (c) => {\n\t\t\t\tlet cursor = c\n\n\t\t\t\tconst trackChangeRecords: DatabaseChangeDetails[] = []\n\t\t\t\twhile (cursor) {\n\t\t\t\t\tconst track = cursor.value\n\t\t\t\t\ttrack.directory = directoryId\n\t\t\t\t\tawait cursor.update(track)\n\t\t\t\t\tcursor = await cursor.continue()\n\n\t\t\t\t\ttrackChangeRecords.push({\n\t\t\t\t\t\tkey: track.id,\n\t\t\t\t\t\tstoreName: 'tracks',\n\t\t\t\t\t\toperation: 'update',\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn trackChangeRecords\n\t\t\t})\n\n\t\tconst removedDirectoryPromise = tx\n\t\t\t.objectStore('directories')\n\t\t\t.delete(existingDirId)\n\t\t\t.then(\n\t\t\t\t(): DatabaseChangeDetails => ({\n\t\t\t\t\tkey: existingDirId,\n\t\t\t\t\tstoreName: 'directories',\n\t\t\t\t\toperation: 'delete',\n\t\t\t\t}),\n\t\t\t)\n\n\t\tconst result = await Promise.all([removedDirectoryPromise, updatedTracksPromise])\n\t\treturn result.flat()\n\t})\n\n\tconst [_, ...changes] = await Promise.all([tx.done, replaceHandlePromise, ...promises])\n\n\tdispatchDatabaseChangedEvent(changes.flat())\n\n\tawait scanTracks({\n\t\taction: 'directory-rescan',\n\t\tdirId: directoryId,\n\t\tdirHandle: parentDirHandle,\n\t})\n}\n\nexport const replaceDirectories = async (\n\tparentDirHandle: FileSystemDirectoryHandle,\n\tdirsIds: number[],\n): Promise<void> => {\n\ttry {\n\t\tawait lockDatabase(() => dbReplaceDirectories(parentDirHandle, dirsIds))\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nconst dbRemoveDirectory = async (directoryId: number): Promise<void> => {\n\tconst db = await getDatabase()\n\n\tconst tracksToBeRemoved = await db.getAllKeysFromIndex('tracks', 'directory', directoryId)\n\tawait dbRemoveTracks(tracksToBeRemoved)\n\tawait db.delete('directories', directoryId)\n\n\tdispatchDatabaseChangedEvent({\n\t\tkey: directoryId,\n\t\tstoreName: 'directories',\n\t\toperation: 'delete',\n\t})\n}\n\nexport const removeDirectory = async (id: number): Promise<void> => {\n\ttry {\n\t\tawait lockDatabase(() => dbRemoveDirectory(id))\n\n\t\tsnackbar(m.settingsDirectoryRemoved())\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n\nconst dbImportLegacyFiles = (files: File[]): Promise<void> =>\n\tscanTracks({\n\t\taction: 'legacy-files-add',\n\t\tfiles,\n\t})\n\nexport const importLegacyFiles = async (files: File[]): Promise<void> => {\n\ttry {\n\t\tawait lockDatabase(() => dbImportLegacyFiles(files))\n\t} catch (error) {\n\t\tsnackbar.unexpectedError(error)\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scan-tracks.ts",
    "content": "import type { TracksScanOptions } from './scanner/start.ts'\n\nexport const scanTracks = async (options: TracksScanOptions): Promise<void> => {\n\tconst snackbarId = 'scan-tracks'\n\tsnackbar({\n\t\tid: snackbarId,\n\t\tmessage: m.settingsPreparingForScan(),\n\t\tcontrols: false,\n\t\tduration: false,\n\t})\n\n\tconst { startTrackScannerWorker } = await import('./scanner/start.ts')\n\n\tconst result = await startTrackScannerWorker(options, (data) => {\n\t\tsnackbar({\n\t\t\tid: snackbarId,\n\t\t\tmessage: m.settingsScanInProgress({\n\t\t\t\tcurrent: data.current,\n\t\t\t\ttotal: data.total,\n\t\t\t}),\n\t\t\tcontrols: false,\n\t\t\tduration: false,\n\t\t})\n\t})\n\n\tif (result.newlyImported === 0) {\n\t\tsnackbar({\n\t\t\tid: snackbarId,\n\t\t\tmessage: m.settingsScanNoNewTracks(),\n\t\t\tduration: 2000,\n\t\t})\n\t} else {\n\t\tsnackbar({\n\t\t\tid: snackbarId,\n\t\t\tmessage: m.settingsScanNewOrUpdatedTracks({\n\t\t\t\tnewTracks: result.newlyImported,\n\t\t\t}),\n\t\t\tduration: 8000,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/actions.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { type FileEntity, getFileHandlesRecursively } from '$lib/helpers/file-system.ts'\nimport { SerialQueue } from '$lib/helpers/serial-queue.ts'\nimport { dbRemoveTracks } from '$lib/library/remove.ts'\nimport { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts'\nimport { dbImportTrack } from './import-track.ts'\nimport { getArtworkRelatedData } from './parse/format-artwork.ts'\nimport { parseTrackMetadata } from './parse/parse-track.ts'\nimport type { TracksScanMessage, TracksScanOptions } from './types.ts'\n\ndeclare const self: DedicatedWorkerGlobalScope\n\ninterface TrackEnqueueOptions {\n\tscannedAt: number\n\tunwrappedFile: File\n\tfile: FileEntity\n\tdirectoryId: number\n\t/** In cases when track already was imported */\n\ttrackId?: number\n\tuuid?: string\n}\n\n/**\n * A three-stage pipeline for track ingestion:\n * 1. [PARSING]  - Blocks the caller; processes one file at a time.\n * 2. [ARTWORK]  - Background serial queue for I/O-heavy image processing.\n * 3. [IMPORT]   - Background serial queue for final database writes.\n * - This \"conveyor belt\" allows the caller to parse the next track while\n * previous tracks progress through artwork and import stages concurrently.\n */\nclass TrackProcessor {\n\t#artworkQueue = new SerialQueue()\n\t#importQueue = new SerialQueue()\n\n\t#tracker: StatusTracker\n\t#onImportSuccess?: (trackId: number) => void\n\n\tconstructor(tracker: StatusTracker, onImportSuccess?: (trackId: number) => void) {\n\t\tthis.#tracker = tracker\n\t\tthis.#onImportSuccess = onImportSuccess\n\t}\n\n\tasync parseAndEnqueue(options: TrackEnqueueOptions) {\n\t\tconst parsed = await parseTrackMetadata(options.unwrappedFile)\n\t\tif (!parsed) {\n\t\t\treturn\n\t\t}\n\n\t\tthis.#artworkQueue.enqueue(async () => {\n\t\t\tconst artworkData = parsed.imageBlob\n\t\t\t\t? await getArtworkRelatedData(parsed.imageBlob)\n\t\t\t\t: undefined\n\n\t\t\tthis.#importQueue.enqueue(async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst trackId = await dbImportTrack(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t...parsed.data,\n\t\t\t\t\t\t\t...artworkData,\n\t\t\t\t\t\t\tfile: options.file,\n\t\t\t\t\t\t\tdirectory: options.directoryId,\n\t\t\t\t\t\t\tfileName: options.file.name,\n\t\t\t\t\t\t\tscannedAt: options.scannedAt,\n\t\t\t\t\t\t\tuuid: options.uuid ?? crypto.randomUUID(),\n\t\t\t\t\t\t},\n\t\t\t\t\t\toptions.trackId,\n\t\t\t\t\t)\n\n\t\t\t\t\tthis.#onImportSuccess?.(trackId)\n\t\t\t\t\tthis.#tracker.newlyImported += 1\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconsole.error(err)\n\t\t\t\t} finally {\n\t\t\t\t\tthis.#tracker.sendMsg(false)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n\n\tasync drain(): Promise<void> {\n\t\tawait this.#artworkQueue.drain()\n\t\tawait this.#importQueue.drain()\n\t}\n}\n\nclass StatusTracker {\n\tnewlyImported = 0\n\n\tcurrent = 0\n\n\ttotal = 0\n\n\t#pendingTimeout: number | null = null\n\t#hasPendingMessage = false\n\n\t#timeId: string\n\n\tconstructor(total: number, timeId: string) {\n\t\tthis.total = total\n\t\tthis.#timeId = timeId\n\n\t\tconsole.time(this.#timeId)\n\t}\n\n\tsendMsg = (finished: boolean) => {\n\t\tif (finished) {\n\t\t\tconsole.timeEnd(this.#timeId)\n\t\t\tif (this.#pendingTimeout) {\n\t\t\t\tself.clearTimeout(this.#pendingTimeout)\n\t\t\t}\n\t\t} else {\n\t\t\tif (this.#pendingTimeout) {\n\t\t\t\tthis.#hasPendingMessage = true\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.#pendingTimeout = self.setTimeout(() => {\n\t\t\t\tthis.#pendingTimeout = null\n\t\t\t\tif (this.#hasPendingMessage) {\n\t\t\t\t\tthis.#hasPendingMessage = false\n\t\t\t\t\tthis.#postMessage(false)\n\t\t\t\t}\n\t\t\t}, 200)\n\t\t}\n\n\t\tthis.#postMessage(finished)\n\t}\n\n\t#postMessage = (finished: boolean): void => {\n\t\tconst message: TracksScanMessage = {\n\t\t\tfinished,\n\t\t\tcount: {\n\t\t\t\tnewlyImported: this.newlyImported,\n\t\t\t\tcurrent: this.current,\n\t\t\t\ttotal: this.total,\n\t\t\t},\n\t\t}\n\n\t\tself.postMessage(message)\n\t}\n}\n\nconst findTrackByFileHandle = async (handle: FileSystemFileHandle, tracks: Track[]) => {\n\tfor (const track of tracks) {\n\t\tconst isSame = await handle.isSameEntry(track.file as FileSystemFileHandle)\n\t\tif (isSame) {\n\t\t\treturn track\n\t\t}\n\t}\n\n\treturn null\n}\n\nconst findTrackByMixedFileEntity = async (handle: FileEntity, tracks: Track[]) => {\n\tfor (const track of tracks) {\n\t\tconst existingFile = track.file\n\t\t// If file name is changed we can be sure it's not the same file anymore\n\t\tif (existingFile.name !== handle.name) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif (\n\t\t\texistingFile instanceof FileSystemFileHandle &&\n\t\t\thandle instanceof FileSystemFileHandle\n\t\t) {\n\t\t\tconst isSame = await handle.isSameEntry(existingFile)\n\t\t\tif (isSame) {\n\t\t\t\treturn track\n\t\t\t}\n\t\t}\n\n\t\t// No reliable way to compare two Files,\n\t\t// so we compare their names and size\n\t\tif (\n\t\t\texistingFile instanceof File &&\n\t\t\thandle instanceof File &&\n\t\t\texistingFile.size === handle.size\n\t\t) {\n\t\t\treturn track\n\t\t}\n\t}\n\n\treturn null\n}\n\nconst scanExistingDirectory = async (handles: FileEntity[], directoryId: number) => {\n\tconst db = await getDatabase()\n\n\tconst tracker = new StatusTracker(handles.length, 'SCAN_EXISTING_DIR')\n\tconst scannedTracksIds = new Set<number>()\n\tconst processor = new TrackProcessor(tracker, (trackId) => {\n\t\tscannedTracksIds.add(trackId)\n\t})\n\n\tconst scannedAt = Date.now()\n\n\tconst findTrackFn =\n\t\tdirectoryId === LEGACY_NO_NATIVE_DIRECTORY\n\t\t\t? findTrackByMixedFileEntity\n\t\t\t: findTrackByFileHandle\n\n\tfor (const handle of handles) {\n\t\ttracker.current += 1\n\n\t\ttry {\n\t\t\t// Real FS might have multiple files with the same name\n\t\t\t// but in the database we keep flat structure\n\t\t\tconst possibleExistingTracks = await db.getAllFromIndex('tracks', 'path', [\n\t\t\t\tdirectoryId,\n\t\t\t\thandle.name,\n\t\t\t])\n\t\t\tconst existingTrack = await findTrackFn(\n\t\t\t\t// If `LEGACY_NO_NATIVE_DIRECTORY` is used this will be a `File` or `FileSystemFileHandle`\n\t\t\t\t// in all other cases it will be a `FileSystemFileHandle`\n\t\t\t\thandle as FileSystemFileHandle,\n\t\t\t\tpossibleExistingTracks,\n\t\t\t)\n\n\t\t\tconst unwrappedFile = handle instanceof File ? handle : await handle.getFile()\n\n\t\t\t// File was not modified since last scan\n\t\t\tif (existingTrack && unwrappedFile.lastModified <= existingTrack.scannedAt) {\n\t\t\t\tscannedTracksIds.add(existingTrack.id)\n\t\t\t\ttracker.sendMsg(false)\n\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tawait processor.parseAndEnqueue({\n\t\t\t\tunwrappedFile,\n\t\t\t\tfile: handle,\n\t\t\t\tdirectoryId,\n\t\t\t\ttrackId: existingTrack?.id,\n\t\t\t\tuuid: existingTrack?.uuid,\n\t\t\t\tscannedAt,\n\t\t\t})\n\t\t} catch {\n\t\t\t// we ignore errors and just move on to the next track.\n\t\t}\n\t}\n\n\tawait processor.drain()\n\n\tif (directoryId === LEGACY_NO_NATIVE_DIRECTORY) {\n\t\ttracker.sendMsg(true)\n\n\t\treturn\n\t}\n\n\t// After importing is done, we remove tracks that were not scanned\n\t// meaning they do not exist in the actual FS anymore\n\tconst tracksIdsInDirectory = await db.getAllKeysFromIndex('tracks', 'directory', directoryId)\n\tconst tracksToRemove: number[] = []\n\tfor (const trackId of tracksIdsInDirectory) {\n\t\tif (!scannedTracksIds.has(trackId)) {\n\t\t\ttracksToRemove.push(trackId)\n\t\t}\n\t}\n\tawait dbRemoveTracks(tracksToRemove).catch(console.warn)\n\n\ttracker.sendMsg(true)\n}\n\nconst scanNewDirectory = async (handles: FileEntity[], directoryId: number) => {\n\tconst tracker = new StatusTracker(handles.length, 'SCAN_NEW_DIR')\n\tconst processor = new TrackProcessor(tracker)\n\tconst scannedAt = Date.now()\n\n\tfor (const handle of handles) {\n\t\ttracker.current += 1\n\n\t\ttry {\n\t\t\tconst unwrappedFile = handle instanceof File ? handle : await handle.getFile()\n\n\t\t\tawait processor.parseAndEnqueue({\n\t\t\t\tunwrappedFile,\n\t\t\t\tfile: handle,\n\t\t\t\tdirectoryId,\n\t\t\t\tscannedAt,\n\t\t\t})\n\t\t} catch {\n\t\t\t// we ignore errors and just move on to the next track\n\t\t}\n\t}\n\n\tawait processor.drain()\n\n\ttracker.sendMsg(true)\n}\n\nexport const workerAction = async (options: TracksScanOptions) => {\n\tif (options.action === 'directory-add') {\n\t\tconst handles = await getFileHandlesRecursively(options.dirHandle)\n\t\tawait scanNewDirectory(handles, options.dirId)\n\n\t\treturn\n\t}\n\n\tif (options.action === 'directory-rescan') {\n\t\tconst handles = await getFileHandlesRecursively(options.dirHandle)\n\t\tawait scanExistingDirectory(handles, options.dirId)\n\n\t\treturn\n\t}\n\n\tif (options.action === 'legacy-files-add') {\n\t\tawait scanExistingDirectory(options.files, LEGACY_NO_NATIVE_DIRECTORY)\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/import-track.ts",
    "content": "import type { IDBPTransaction } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimport {\n\ttype Album,\n\ttype Artist,\n\ttype Track,\n\tUNKNOWN_ITEM,\n\ttype UnknownTrack,\n} from '$lib/library/types.ts'\n\ntype ImportTrackTx = IDBPTransaction<\n\tAppDB,\n\t('tracks' | 'albums' | 'artists' | 'playlistEntries')[],\n\t'readwrite'\n>\n\nconst dbImportAlbum = async (tx: ImportTrackTx, track: Track) => {\n\tconst albumName = track.album\n\n\tconst store = tx.objectStore('albums')\n\n\tconst existingAlbum = await store.index('name').get(albumName)\n\tconst updatedAlbum: Omit<Album, 'id'> = existingAlbum\n\t\t? {\n\t\t\t\t...existingAlbum,\n\t\t\t\tartists: [...new Set([...existingAlbum.artists, ...track.artists])].filter(\n\t\t\t\t\t(artist) => artist !== UNKNOWN_ITEM,\n\t\t\t\t),\n\t\t\t\tyear: existingAlbum.year ?? track.year,\n\t\t\t\timage: existingAlbum.image ?? track.image?.full,\n\t\t\t}\n\t\t: {\n\t\t\t\tuuid: crypto.randomUUID(),\n\t\t\t\tname: albumName,\n\t\t\t\tartists: track.artists,\n\t\t\t\tyear: track.year,\n\t\t\t\timage: track.image?.full,\n\t\t\t}\n\n\tconst albumId = await store.put(updatedAlbum as Album)\n\n\tconst change: DatabaseChangeDetails = {\n\t\tstoreName: 'albums',\n\t\tkey: albumId,\n\t\toperation: existingAlbum ? 'update' : 'add',\n\t}\n\n\treturn change\n}\n\nconst dbImportArtist = async (tx: ImportTrackTx, artistName: string) => {\n\tconst store = tx.objectStore('artists')\n\n\tconst existingArtistId = await store.index('name').getKey(artistName)\n\tif (existingArtistId) {\n\t\treturn\n\t}\n\n\tconst newArtist: Omit<Artist, 'id'> = {\n\t\tname: artistName,\n\t\tuuid: crypto.randomUUID(),\n\t}\n\n\tconst artistId = await store.put(newArtist as Artist)\n\n\tconst change: DatabaseChangeDetails = {\n\t\tstoreName: 'artists',\n\t\tkey: artistId,\n\t\toperation: 'add',\n\t}\n\n\treturn change\n}\n\nconst dbImportArtists = (tx: ImportTrackTx, artistNames: string[]) =>\n\tPromise.all(artistNames.map(async (artist) => dbImportArtist(tx, artist)))\n\nexport const dbImportTrack = async (\n\tmetadata: UnknownTrack,\n\texistingTrackId: number | undefined,\n): Promise<number> => {\n\tconst db = await getDatabase()\n\tconst tx = db.transaction(['tracks', 'albums', 'artists', 'playlistEntries'], 'readwrite')\n\n\tconst trackId = await tx.objectStore('tracks').put(metadata as Track, existingTrackId)\n\tconst track: Track = {\n\t\t...metadata,\n\t\tid: trackId,\n\t}\n\n\tconst [albumChange, artistsChanges] = await Promise.all([\n\t\tdbImportAlbum(tx, track),\n\t\tdbImportArtists(tx, track.artists),\n\t\ttx.done,\n\t])\n\n\tdispatchDatabaseChangedEvent([\n\t\t{\n\t\t\tstoreName: 'tracks',\n\t\t\tkey: trackId,\n\t\t\toperation: existingTrackId === trackId ? 'update' : 'add',\n\t\t},\n\t\talbumChange,\n\t\t...artistsChanges,\n\t])\n\n\treturn trackId\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/format-artwork.ts",
    "content": "import { isSafari as isSafariCheck } from '$lib/helpers/utils/ua.ts'\nimport { getPrimaryColor } from './image-primary-color.ts'\n\nconst getSmallImageDimensions = (\n\toriginalWidth: number,\n\toriginalHeight: number,\n): [width: number, height: number] => {\n\tconst smallerTarget = Math.min(originalWidth, originalHeight, 100)\n\n\tif (originalWidth === originalHeight) {\n\t\treturn [smallerTarget, smallerTarget]\n\t}\n\n\tif (originalWidth > originalHeight) {\n\t\tconst ratio = originalHeight / originalWidth\n\n\t\treturn [smallerTarget, Math.floor(smallerTarget * ratio)]\n\t}\n\n\tconst ratio = originalWidth / originalHeight\n\n\treturn [Math.floor(smallerTarget * ratio), smallerTarget]\n}\n\nexport interface ArtworkRelatedData {\n\timage: {\n\t\toptimized: boolean\n\t\tfull: Blob\n\t\tsmall: Blob\n\t}\n\tprimaryColor: number | undefined\n}\n\nconst isSafari = isSafariCheck()\n\n/** @public */\nexport const getArtworkRelatedData = async (imageBlob: Blob): Promise<ArtworkRelatedData> => {\n\tlet bitmap: ImageBitmap | undefined\n\ttry {\n\t\tbitmap = await createImageBitmap(imageBlob)\n\t\tconst [tw, th] = getSmallImageDimensions(bitmap.width, bitmap.height)\n\n\t\tconst canvas = new OffscreenCanvas(tw, th)\n\t\tconst ctx = canvas.getContext('2d')\n\t\tinvariant(ctx)\n\n\t\t// Draw smaller image version\n\t\tctx.drawImage(bitmap, 0, 0, tw, th)\n\n\t\tconst data = ctx.getImageData(0, 0, tw, th).data\n\n\t\tconst primaryColor = getPrimaryColor(data, tw, th)\n\n\t\treturn {\n\t\t\timage: {\n\t\t\t\toptimized: true,\n\t\t\t\tfull: imageBlob,\n\t\t\t\tsmall: await canvas.convertToBlob({\n\t\t\t\t\ttype: isSafari ? 'image/png' : 'image/webp',\n\t\t\t\t\tquality: 0.7,\n\t\t\t\t}),\n\t\t\t},\n\t\t\tprimaryColor,\n\t\t}\n\t} catch (err) {\n\t\tconsole.error('Failed to optimize artwork', err)\n\n\t\treturn {\n\t\t\timage: {\n\t\t\t\toptimized: false,\n\t\t\t\tfull: imageBlob,\n\t\t\t\tsmall: imageBlob,\n\t\t\t},\n\t\t\tprimaryColor: undefined,\n\t\t}\n\t} finally {\n\t\tbitmap?.close()\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/image-primary-color.ts",
    "content": "const SHIFT = 3 // quantize 8-bit -> 5-bit\nconst BINS = 32 * 32 * 32 // 5-bit bins\nconst hueBins = 360 // hue histogram bins (1° each) - increased for better resolution\nconst hueWindow = 24 // accept ±24° around peak hue\nconst alphaThreshold = 240 // ignore semi-transparent\nconst minSat = 0.15 // ignore near-gray - increased to focus on vibrant colors\nconst minVal = 0.15 // ignore near-black - increased to avoid dark noise\nconst whiteSkipV = 0.94 // ignore near-white if also low sat\nconst whiteSkipS = 0.15\nconst satGamma = 2.2 // stronger accent preference for vibrant colors\nconst valGamma = 0.4 // moderate brightness weight\nconst centerBias = 0.15 // reduced center bias to avoid missing off-center accents\n\n// Helper: convert RGB → HSV\nfunction rgb2hsv(r: number, g: number, b: number) {\n\tconst rf = r / 255\n\tconst gf = g / 255\n\tconst bf = b / 255\n\n\tconst max = Math.max(rf, gf, bf)\n\tconst min = Math.min(rf, gf, bf)\n\tconst d = max - min\n\n\tlet h = 0\n\tif (d > 0) {\n\t\tif (max === rf) {\n\t\t\th = ((gf - bf) / d + (gf < bf ? 6 : 0)) * 60\n\t\t} else if (max === gf) {\n\t\t\th = ((bf - rf) / d + 2) * 60\n\t\t} else {\n\t\t\th = ((rf - gf) / d + 4) * 60\n\t\t}\n\t}\n\n\tconst s = max === 0 ? 0 : d / max\n\tconst v = max\n\n\treturn { h, s, v }\n}\n\n/**\n * Extract a single dominant accent color, ignoring white/gray backgrounds.\n * Two-pass approach:\n * \t1. Build a hue histogram (weighted by saturation, brightness, center bias). Pick peak hue.\n * \t2. Only count pixels near that hue, then pick most common 5-bit RGB bin.\n */\nexport function getPrimaryColor(pixels: Uint8ClampedArray, width: number, height: number): number {\n\t// Hue histogram\n\tconst hueHist = new Float64Array(hueBins)\n\n\t// Gaussian center weighting setup\n\tconst cx = (width - 1) / 2\n\tconst cy = (height - 1) / 2\n\tconst sigma = 0.35 * Math.min(width, height)\n\tconst twoSigma2 = 2 * sigma * sigma\n\n\t// PASS 1: Fill hue histogram\n\tlet col = 0\n\tlet row = 0\n\tfor (let i = 0; i < pixels.length; i += 4) {\n\t\tconst a = pixels[i + 3] as number\n\t\tif (a < alphaThreshold) {\n\t\t\tcol += 1\n\t\t\tif (col >= width) {\n\t\t\t\tcol = 0\n\t\t\t\trow += 1\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tconst r = pixels[i] as number\n\t\tconst g = pixels[i + 1] as number\n\t\tconst b = pixels[i + 2] as number\n\t\tconst { h, s, v } = rgb2hsv(r, g, b)\n\n\t\t// Skip unwanted pixels (white bg, gray, dark)\n\t\tif ((v > whiteSkipV && s < whiteSkipS) || s < minSat || v < minVal) {\n\t\t\tcol += 1\n\t\t\tif (col >= width) {\n\t\t\t\tcol = 0\n\t\t\t\trow += 1\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Weight = saturation^γ * brightness^γ * centerWeight\n\t\tlet w = s ** satGamma * v ** valGamma\n\t\tconst dx = col - cx\n\t\tconst dy = row - cy\n\t\tconst centerW = Math.exp(-(dx * dx + dy * dy) / twoSigma2)\n\t\tw = (1 - centerBias) * w + centerBias * (w * centerW)\n\n\t\tconst bin = Math.floor((h / 360) * hueBins) % hueBins\n\t\t;(hueHist[bin] as number) += w\n\n\t\tcol += 1\n\t\tif (col >= width) {\n\t\t\tcol = 0\n\t\t\trow += 1\n\t\t}\n\t}\n\n\t// Smooth histogram and pick peak hue\n\tconst smooth = new Float64Array(hueBins)\n\tfor (let i = 0; i < hueBins; i += 1) {\n\t\tconst im1 = (i - 1 + hueBins) % hueBins\n\t\tconst ip1 = (i + 1) % hueBins\n\t\tsmooth[i] =\n\t\t\t0.5 * (hueHist[im1] as number) + (hueHist[i] as number) + 0.5 * (hueHist[ip1] as number)\n\t}\n\tlet peak = 0\n\tfor (let i = 1; i < hueBins; i += 1) {\n\t\tif ((smooth[i] as number) > (smooth[peak] as number)) {\n\t\t\tpeak = i\n\t\t}\n\t}\n\tconst peakHue = (peak + 0.5) * (360 / hueBins)\n\n\t// PASS 2: Build restricted RGB histogram and track actual pixel colors\n\tconst counts = new Float64Array(BINS)\n\tconst rSums = new Float64Array(BINS)\n\tconst gSums = new Float64Array(BINS)\n\tconst bSums = new Float64Array(BINS)\n\tcol = 0\n\trow = 0\n\n\tfunction inHueWindow(h: number) {\n\t\tlet dh = Math.abs(h - peakHue)\n\t\tdh = Math.min(dh, 360 - dh)\n\t\treturn dh <= hueWindow\n\t}\n\n\tfor (let i = 0; i < pixels.length; i += 4) {\n\t\tconst a = pixels[i + 3] as number\n\t\tif (a < alphaThreshold) {\n\t\t\tcol += 1\n\t\t\tif (col >= width) {\n\t\t\t\tcol = 0\n\t\t\t\trow += 1\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tconst r = pixels[i] as number\n\t\tconst g = pixels[i + 1] as number\n\t\tconst b = pixels[i + 2] as number\n\t\tconst { h, s, v } = rgb2hsv(r, g, b)\n\n\t\tif ((v > whiteSkipV && s < whiteSkipS) || s < minSat || v < minVal) {\n\t\t\tcol += 1\n\t\t\tif (col >= width) {\n\t\t\t\tcol = 0\n\t\t\t\trow += 1\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif (!inHueWindow(h)) {\n\t\t\tcol += 1\n\t\t\tif (col >= width) {\n\t\t\t\tcol = 0\n\t\t\t\trow += 1\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tlet w = s ** satGamma * v ** valGamma\n\t\tconst dx = col - cx\n\t\tconst dy = row - cy\n\t\tconst centerW = Math.exp(-(dx * dx + dy * dy) / twoSigma2)\n\t\tw = (1 - centerBias) * w + centerBias * (w * centerW)\n\n\t\tconst idx = ((r >> SHIFT) << 10) | ((g >> SHIFT) << 5) | (b >> SHIFT)\n\t\t;(counts[idx] as number) += w\n\t\t;(rSums[idx] as number) += r * w\n\t\t;(gSums[idx] as number) += g * w\n\t\t;(bSums[idx] as number) += b * w\n\n\t\tcol += 1\n\t\tif (col >= width) {\n\t\t\tcol = 0\n\t\t\trow += 1\n\t\t}\n\t}\n\n\t// Find winner bin\n\tlet bestIdx = -1\n\tlet bestW = -1\n\tfor (let idx = 0; idx < BINS; idx += 1) {\n\t\tconst w = counts[idx] as number\n\t\tif (w > bestW) {\n\t\t\tbestW = w\n\t\t\tbestIdx = idx\n\t\t}\n\t}\n\tif (bestIdx < 0 || bestW === 0) {\n\t\treturn 0xff_00_00_00\n\t}\n\n\t// Calculate weighted average of actual pixel colors in the winning bin\n\tconst R = Math.round((rSums[bestIdx] as number) / bestW)\n\tconst G = Math.round((gSums[bestIdx] as number) / bestW)\n\tconst B = Math.round((bSums[bestIdx] as number) / bestW)\n\n\treturn (0xff << 24) | (R << 16) | (G << 8) | B\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/parse-track.ts",
    "content": "import { parseBuffer } from 'music-metadata'\nimport { type ParsedTrackData, UNKNOWN_ITEM } from '$lib/library/types.ts'\n\n// This limit is a bit arbitrary.\nconst FILE_SIZE_LIMIT_300MB = 300 * 1024 * 1024\n\nconst artistSeparatorRegex = /,|&/\n\n/** @public */\nexport const parseTrackMetadata = async (\n\tfile: File,\n): Promise<{ data: ParsedTrackData; imageBlob?: Blob } | null> => {\n\t// Ignore files bigger than limit because of\n\t// potential performance issues.\n\tif (file.size > FILE_SIZE_LIMIT_300MB) {\n\t\treturn null\n\t}\n\n\t// Loading whole file into memory all at once is faster than streaming it,\n\t// especially on Android where many small FS reads can be very slow.\n\tconst arrayBuffer = await file.arrayBuffer()\n\tconst buffer = new Uint8Array(arrayBuffer)\n\n\tconst tags = await parseBuffer(\n\t\tbuffer,\n\t\t{\n\t\t\tmimeType: file.type,\n\t\t\tsize: file.size,\n\t\t},\n\t\t{\n\t\t\tduration: true,\n\t\t\tmkvUseIndex: true,\n\t\t\tskipPostHeaders: true,\n\t\t},\n\t)\n\n\tconst { common } = tags\n\n\tlet imageBlob: Blob | undefined\n\tconst picture = common.picture?.[0]\n\tif (picture) {\n\t\tconst imageData = new Uint8ClampedArray(picture.data)\n\t\timageBlob = new Blob([imageData], { type: picture.format })\n\t}\n\n\tconst artists =\n\t\tcommon.artists\n\t\t\t?.flatMap((artist) => artist.split(artistSeparatorRegex))\n\t\t\t.map((artist) => artist.trim()) ?? []\n\n\tconst trackData: ParsedTrackData = {\n\t\tname: common.title || file.name,\n\t\talbum: common.album ?? UNKNOWN_ITEM,\n\t\tartists: artists.length > 0 ? artists : [UNKNOWN_ITEM],\n\t\tgenre: common.genre || [],\n\t\ttrackNo: common.track.no ?? 0,\n\t\ttrackOf: common.track.of ?? 0,\n\t\tdiscNo: common.disk.no ?? 0,\n\t\tdiscOf: common.disk.of ?? 0,\n\t\tyear: common.year?.toString() ?? UNKNOWN_ITEM,\n\t\tduration: tags.format.duration ?? 0,\n\t\tlanguage: common.language?.trim(),\n\t}\n\n\treturn {\n\t\tdata: trackData,\n\t\timageBlob,\n\t}\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/start.ts",
    "content": "import type { TracksScanMessage, TracksScanOptions, TracksScanResult } from './types.ts'\nimport TracksWorker from './worker.ts?worker'\n\nexport type {\n\t/** @public */\n\tTracksScanOptions,\n\t/** @public */\n\tTracksScanResult,\n} from './types.ts'\n\n/** @public */\nexport type TrackParsedFn = (totalParsedCount: number) => void\n\n/** @public */\nexport const startTrackScannerWorker = (\n\toptions: TracksScanOptions,\n\tprogress: (data: TracksScanResult) => void,\n): Promise<TracksScanResult> => {\n\tconst { promise, reject, resolve } = Promise.withResolvers<TracksScanResult>()\n\n\tconst worker = new TracksWorker()\n\n\tworker.addEventListener('error', reject)\n\tworker.addEventListener('message', ({ data }: MessageEvent<TracksScanMessage>) => {\n\t\tif (data.finished) {\n\t\t\tworker.terminate()\n\t\t\tresolve(data.count)\n\t\t} else {\n\t\t\tprogress(data.count)\n\t\t}\n\t})\n\n\tworker.postMessage(options)\n\n\treturn promise\n}\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/types.ts",
    "content": "import type { FileEntity } from '$lib/helpers/file-system'\n\nexport interface TracksScanResult {\n\t/** Count of many tracks were newly added */\n\tnewlyImported: number\n\t/** Index of currently scanned track */\n\tcurrent: number\n\t/** Total count of tracks */\n\ttotal: number\n}\n\nexport interface TracksScanMessage {\n\tfinished: boolean\n\tcount: TracksScanResult\n}\n\n/** @public */\nexport type TracksScanOptions =\n\t| {\n\t\t\taction: 'directory-add' | 'directory-rescan'\n\t\t\tdirId: number\n\t\t\tdirHandle: FileSystemDirectoryHandle\n\t  }\n\t// Used in the browsers which do not support `showDirectoryPicker`\n\t| {\n\t\t\taction: 'legacy-files-add'\n\t\t\tfiles: FileEntity[]\n\t  }\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/worker.ts",
    "content": "/// <reference lib='WebWorker' />\n\nimport { workerAction } from './actions.ts'\nimport type { TracksScanOptions } from './types.ts'\n\ndeclare const self: DedicatedWorkerGlobalScope\n\nself.addEventListener('message', async (event: MessageEvent<TracksScanOptions>) => {\n\tconst options = event.data\n\n\ttry {\n\t\tawait workerAction(options)\n\t} catch (err) {\n\t\tself.postMessage({ finished: true, count: { newlyImported: 0, current: 0, total: 0 } })\n\t\tconsole.error('[scanner worker]', err)\n\t}\n})\n"
  },
  {
    "path": "src/lib/library/tracks-queries.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts'\n\nexport const createTracksCountPageQuery = (): Promise<PageQueryResult<number>> =>\n\tcreatePageQuery({\n\t\tkey: [],\n\t\tfetcher: async () => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\treturn db.count('tracks')\n\t\t},\n\t\tonDatabaseChange: (changes, { mutate }) => {\n\t\t\tlet countDiff = 0\n\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === 'tracks') {\n\t\t\t\t\tconst { operation } = change\n\n\t\t\t\t\tif (operation === 'add') {\n\t\t\t\t\t\tcountDiff += 1\n\t\t\t\t\t} else if (operation === 'delete') {\n\t\t\t\t\t\tcountDiff -= 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (countDiff !== 0) {\n\t\t\t\tmutate((v = 0) => v + countDiff)\n\t\t\t}\n\t\t},\n\t})\n"
  },
  {
    "path": "src/lib/library/types.ts",
    "content": "import type { FileEntity } from '$lib/helpers/file-system.ts'\n\nexport type LibraryStoreName = 'tracks' | 'albums' | 'artists' | 'playlists'\n\n/**\n * Used in browsers where `showDirectoryPicker` is not supported.\n * `file` field is gonna be `File` in those browsers,\n * or if user has tracks from previous application version\n * where directories were not used `FileSystemHandle`.\n */\nexport const LEGACY_NO_NATIVE_DIRECTORY = -1\n\n/** Special type of playlist which user cannot modify */\nexport const FAVORITE_PLAYLIST_ID = -1\nexport const FAVORITE_PLAYLIST_UUID = 'favorites'\n\n/**\n * Used to represent unknown Artist/Album and other values inside database\n * Using ~ so when sorting items are always at the end\n */\nexport const UNKNOWN_ITEM = '~\\0unknown'\n\nexport type UnknownItem = typeof UNKNOWN_ITEM\n\nexport type StringOrUnknownItem = (string & {}) | UnknownItem\n\ninterface BaseMusicItem {\n\tid: number\n\tname: string\n}\n\nexport interface ParsedTrackData {\n\tname: string\n\talbum: StringOrUnknownItem\n\tartists: StringOrUnknownItem[]\n\tyear: StringOrUnknownItem\n\tduration: number\n\tgenre: string[]\n\ttrackNo: number\n\ttrackOf: number\n\tdiscNo: number\n\tdiscOf: number\n\tlanguage?: string\n\timage?: {\n\t\toptimized: boolean\n\t\tsmall: Blob\n\t\tfull: Blob\n\t}\n\tprimaryColor?: number\n}\n\nexport interface UnknownTrack extends ParsedTrackData {\n\tuuid: string\n\tfile: FileEntity\n\tscannedAt: number\n\tfileName: string\n\tdirectory: number\n}\n\nexport interface Track extends BaseMusicItem, UnknownTrack {}\n\nexport interface Album extends BaseMusicItem {\n\tuuid: string\n\tartists: string[]\n\tyear?: string\n\timage?: Blob\n}\n\nexport interface Artist extends BaseMusicItem {\n\tuuid: string\n}\n\nexport interface Playlist extends BaseMusicItem {\n\tuuid: string\n\tdescription: string\n\tcreatedAt: number\n}\n\nexport interface PlaylistEntry {\n\tid: number\n\tplaylistId: number\n\ttrackId: number\n\taddedAt: number\n}\n\nexport interface PlayHistoryEntry {\n\tid: number\n\ttrackId: number\n\tplayedAt: number\n}\n\nexport interface Directory {\n\tid: number\n\thandle: FileSystemDirectoryHandle\n}\n"
  },
  {
    "path": "src/lib/menu-actions/playlists.ts",
    "content": "import type { MenuItem } from '$lib/components/menu/types.ts'\nimport type { Playlist } from '$lib/library/types.ts'\nimport type { DialogsStore } from '$lib/stores/dialogs/store.svelte.ts'\n\nexport const getPlaylistMenuItems = (dialogs: DialogsStore, playlist: Playlist): MenuItem[] => [\n\t{\n\t\tlabel: m.libraryEditPlaylist(),\n\t\taction: () => {\n\t\t\tdialogs.openDialog('editPlaylist', {\n\t\t\t\tid: playlist.id,\n\t\t\t\tname: playlist.name,\n\t\t\t\tdescription: playlist.description,\n\t\t\t})\n\t\t},\n\t},\n\t{\n\t\tlabel: m.libraryRemoveFromLibrary(),\n\t\taction: () => {\n\t\t\tdialogs.openDialog('removeFromLibrary', {\n\t\t\t\ttype: 'single',\n\t\t\t\tid: playlist.id,\n\t\t\t\tname: playlist.name,\n\t\t\t\tstoreName: 'playlists',\n\t\t\t})\n\t\t},\n\t},\n]\n"
  },
  {
    "path": "src/lib/stores/dialogs/store.svelte.ts",
    "content": "import type { ComponentProps } from 'svelte'\nimport type { DialogData, DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\nimport type {\n\tAPP_DIALOGS_COMPONENTS_MAP,\n\tAppDialogKey,\n} from '$lib/components/global-dialogs/dialogs'\n\ntype DialogOpenProp<K extends AppDialogKey> = ComponentProps<\n\t(typeof APP_DIALOGS_COMPONENTS_MAP)[K]\n>['open']\n\ntype StateMap = {\n\t[K in AppDialogKey]?: DialogData<DialogOpenProp<K>>\n}\n\ntype DialogState<K extends AppDialogKey> = NonNullable<StateMap[K]>\n\ntype BooleanProps = {\n\t[K in AppDialogKey]: DialogState<K> extends boolean ? K : never\n}[AppDialogKey]\n\nexport class DialogsStore {\n\t#stateMap: StateMap = $state({})\n\n\t/** Returning new object each time is fine since components are rendered only once on app load */\n\t// biome-ignore lint/suspicious/noExplicitAny: dialog component fails to infer it\n\tgetAccessor(key: AppDialogKey): DialogOpenAccessor<any> {\n\t\treturn {\n\t\t\tget: () => this.#stateMap[key] ?? null,\n\t\t\tclose: () => {\n\t\t\t\tthis.closeDialog(key)\n\t\t\t},\n\t\t}\n\t}\n\n\topenDialog<K extends BooleanProps>(dialog: K, open?: boolean): void\n\topenDialog<K extends Exclude<AppDialogKey, BooleanProps>>(dialog: K, open: DialogState<K>): void\n\topenDialog<K extends AppDialogKey>(dialog: K, open: DialogState<K>) {\n\t\tthis.#stateMap[dialog] = open ?? true\n\t}\n\n\tcloseDialog<K extends AppDialogKey>(dialog: K) {\n\t\tthis.#stateMap[dialog] = undefined\n\t}\n}\n"
  },
  {
    "path": "src/lib/stores/dialogs/use-store.ts",
    "content": "import { createContext } from 'svelte'\nimport type { DialogsStore } from './store.svelte.ts'\n\nexport const [useDialogsStore, setDialogsStoreContext] = createContext<DialogsStore>()\n"
  },
  {
    "path": "src/lib/stores/main/store.svelte.ts",
    "content": "import { prefersReducedMotion } from 'svelte/motion'\nimport { MediaQuery } from 'svelte/reactivity'\nimport { supportsChangingAudioVolume } from '$lib/helpers/audio.ts'\nimport { getPersistedValue, persist } from '$lib/helpers/persist.svelte.ts'\nimport { isMobile } from '$lib/helpers/utils/ua.ts'\n\nexport type AppTheme = 'light' | 'dark'\nexport type AppThemeOption = AppTheme | 'auto'\n\nexport type AppMotion = 'normal' | 'reduced'\nexport type AppMotionOption = AppMotion | 'auto'\n\nexport const getPersistedLibrarySplitLayoutEnabled = (): boolean =>\n\tgetPersistedValue('main', 'librarySplitLayoutEnabled', true)\n\nexport class MainStore {\n\ttheme: AppThemeOption = $state('auto')\n\n\t#deviceThemeDark = new MediaQuery('(prefers-color-scheme: dark)')\n\n\tget isThemeDark(): boolean {\n\t\tif (this.theme === 'auto') {\n\t\t\treturn this.#deviceThemeDark.current\n\t\t}\n\n\t\treturn this.theme === 'dark'\n\t}\n\n\tmotion: AppMotionOption = $state('auto')\n\n\tget isReducedMotion(): boolean {\n\t\tconst motion = this.motion === 'auto' ? prefersReducedMotion.current : this.motion\n\n\t\treturn motion === 'reduced'\n\t}\n\n\tpickColorFromArtwork: boolean = $state(true)\n\n\tcustomThemePaletteHex: string | null = $state(null)\n\n\t/**\n\t * Controls whatever volume slider is visible.\n\t * The initial value is false for mobile devices and true for desktop.\n\t * User can change this setting.\n\t */\n\tvolumeSliderEnabled: boolean = $state(supportsChangingAudioVolume() ? !isMobile() : false)\n\n\tappInstallPromptEvent: BeforeInstallPromptEvent | null = $state(null)\n\n\tlibrarySplitLayoutEnabled: boolean = $state(true)\n\n\tconstructor() {\n\t\tpersist('main', this, [\n\t\t\t'theme',\n\t\t\t'motion',\n\t\t\t'pickColorFromArtwork',\n\t\t\t'customThemePaletteHex',\n\t\t\t'volumeSliderEnabled',\n\t\t\t'librarySplitLayoutEnabled',\n\t\t])\n\t}\n}\n"
  },
  {
    "path": "src/lib/stores/main/use-store.ts",
    "content": "import { createContext } from 'svelte'\nimport type { MainStore } from './store.svelte.ts'\n\nexport const [useMainStore, setMainStoreContext] = createContext<MainStore>()\n"
  },
  {
    "path": "src/lib/stores/player/__test__/audio-loader.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest'\n\nvi.mock('$lib/helpers/utils/ua', () => ({\n\tisAndroid: () => false,\n\tisChromiumBased: () => false,\n}))\n\nimport { AudioLoader } from '$lib/stores/player/audio-loader.svelte.ts'\n\nconst createObjectURL = vi.fn((file: File) => `blob:mock/${file.name}`)\nconst revokeObjectURL = vi.fn()\n\nvi.stubGlobal('URL', {\n\tcreateObjectURL,\n\trevokeObjectURL,\n})\n\nconst makeHandle = (permission: PermissionState, file = new File([''], 'track.mp3')) =>\n\t({\n\t\tqueryPermission: vi.fn().mockResolvedValue(permission),\n\t\trequestPermission: vi.fn().mockResolvedValue(permission),\n\t\tgetFile: vi.fn().mockResolvedValue(file),\n\t}) as unknown as FileSystemFileHandle\n\nconst makeSlowHandle = () => {\n\tconst { promise, resolve } = Promise.withResolvers<File>()\n\n\tconst handle = {\n\t\tqueryPermission: vi.fn().mockResolvedValue('granted' as PermissionState),\n\t\tgetFile: vi.fn().mockReturnValue(promise),\n\t} as unknown as FileSystemFileHandle\n\n\treturn { handle, resolveFile: resolve }\n}\n\ndescribe('AudioLoader', () => {\n\tafterEach(() => {\n\t\tvi.clearAllMocks()\n\t})\n\n\tit('starts with loading false', () => {\n\t\texpect(new AudioLoader(vi.fn()).loading).toBe(false)\n\t})\n\n\tdescribe('load()', () => {\n\t\tit('returns loaded and calls onSrc with the blob URL for a plain File', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\n\t\t\texpect(await loader.load(-1, new File([''], 'song.mp3'))).toEqual({ status: 'loaded' })\n\t\t\texpect(onSrc).toHaveBeenLastCalledWith('blob:mock/song.mp3')\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\n\t\tit('returns loaded for a granted FileSystemFileHandle', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst file = new File([''], 'song.mp3')\n\n\t\t\texpect(await new AudioLoader(onSrc).load(1, makeHandle('granted', file))).toEqual({\n\t\t\t\tstatus: 'loaded',\n\t\t\t})\n\t\t\texpect(onSrc).toHaveBeenLastCalledWith('blob:mock/song.mp3')\n\t\t})\n\n\t\tit('returns failed when permission is denied', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\n\t\t\texpect(await loader.load(1, makeHandle('denied'))).toEqual({\n\t\t\t\tstatus: 'failed',\n\t\t\t\treason: 'permission-denied',\n\t\t\t})\n\t\t\texpect(onSrc).not.toHaveBeenCalledWith(expect.stringContaining('blob:'))\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\n\t\tit('returns failed when prompt permission request throws', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\t\t\tconst handle = {\n\t\t\t\tqueryPermission: vi.fn().mockResolvedValue('prompt' as PermissionState),\n\t\t\t\trequestPermission: vi.fn().mockRejectedValue(new Error('user activation required')),\n\t\t\t\tgetFile: vi.fn(),\n\t\t\t} as unknown as FileSystemFileHandle\n\n\t\t\texpect(await loader.load(1, handle)).toEqual({\n\t\t\t\tstatus: 'failed',\n\t\t\t\treason: 'permission-denied',\n\t\t\t})\n\t\t\texpect(handle.requestPermission).toHaveBeenCalled()\n\t\t\texpect(handle.getFile).not.toHaveBeenCalled()\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\n\t\tit('returns failed with reason not-found when getFile throws NotFoundError', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\t\t\tconst handle = {\n\t\t\t\tqueryPermission: vi.fn().mockResolvedValue('granted' as PermissionState),\n\t\t\t\trequestPermission: vi.fn(),\n\t\t\t\tgetFile: vi.fn().mockRejectedValue(new DOMException('missing', 'NotFoundError')),\n\t\t\t} as unknown as FileSystemFileHandle\n\n\t\t\texpect(await loader.load(1, handle)).toEqual({\n\t\t\t\tstatus: 'failed',\n\t\t\t\treason: 'not-found',\n\t\t\t})\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\n\t\tit('returns failed with reason error when getFile throws unknown error', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\t\t\tconst consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})\n\t\t\tconst handle = {\n\t\t\t\tqueryPermission: vi.fn().mockResolvedValue('granted' as PermissionState),\n\t\t\t\trequestPermission: vi.fn(),\n\t\t\t\tgetFile: vi.fn().mockRejectedValue(new Error('boom')),\n\t\t\t} as unknown as FileSystemFileHandle\n\n\t\t\texpect(await loader.load(1, handle)).toEqual({\n\t\t\t\tstatus: 'failed',\n\t\t\t\treason: 'error',\n\t\t\t})\n\t\t\texpect(consoleError).toHaveBeenCalled()\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\n\t\tit('requests permission when state is prompt', async () => {\n\t\t\tconst handle = makeHandle('prompt')\n\t\t\tawait new AudioLoader(vi.fn()).load(1, handle)\n\t\t\texpect(handle.requestPermission).toHaveBeenCalled()\n\t\t})\n\n\t\tit('revokes previous blob URL before loading a new file', async () => {\n\t\t\tconst loader = new AudioLoader(vi.fn())\n\t\t\tawait loader.load(-1, new File([''], 'first.mp3'))\n\t\t\tawait loader.load(-1, new File([''], 'second.mp3'))\n\t\t\texpect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/first.mp3')\n\t\t})\n\n\t\tit('returns superseded when a newer load races ahead', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\t\t\tconst { handle, resolveFile } = makeSlowHandle()\n\n\t\t\tconst slow = loader.load(1, handle)\n\t\t\tconst fast = loader.load(-1, new File([''], 'fast.mp3'))\n\t\t\tresolveFile(new File([''], 'slow.mp3'))\n\n\t\t\tconst [slowResult, fastResult] = await Promise.all([slow, fast])\n\t\t\texpect(slowResult).toEqual({ status: 'superseded' })\n\t\t\texpect(fastResult).toEqual({ status: 'loaded' })\n\t\t\texpect(onSrc).toHaveBeenLastCalledWith('blob:mock/fast.mp3')\n\t\t})\n\t})\n\n\tdescribe('reset()', () => {\n\t\tit('calls onSrc(null), revokes blob URL, and sets loading false', async () => {\n\t\t\tconst onSrc = vi.fn()\n\t\t\tconst loader = new AudioLoader(onSrc)\n\t\t\tawait loader.load(-1, new File([''], 'song.mp3'))\n\n\t\t\tloader.reset()\n\n\t\t\texpect(onSrc).toHaveBeenLastCalledWith(null)\n\t\t\texpect(loader.loading).toBe(false)\n\t\t\texpect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/song.mp3')\n\t\t})\n\n\t\tit('makes an in-flight load return superseded', async () => {\n\t\t\tconst loader = new AudioLoader(vi.fn())\n\t\t\tconst { handle, resolveFile } = makeSlowHandle()\n\n\t\t\tconst pending = loader.load(1, handle)\n\t\t\tloader.reset()\n\t\t\tresolveFile(new File([''], 'song.mp3'))\n\n\t\t\texpect(await pending).toEqual({ status: 'superseded' })\n\t\t\texpect(loader.loading).toBe(false)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/stores/player/__test__/equalizer.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport { EqualizerStore } from '$lib/stores/player/equalizer.svelte.ts'\n\nvi.mock('$lib/helpers/persist.svelte.ts', () => ({\n\tpersist: vi.fn(),\n}))\n\ninterface MockFilter {\n\tconnect: ReturnType<typeof vi.fn>\n\ttype: BiquadFilterType\n\tfrequency: { value: number }\n\tQ: { value: number }\n\tgain: { value: number }\n}\n\ninterface MockAudioContext {\n\tstate: AudioContextState\n\tdestination: { connect: ReturnType<typeof vi.fn> }\n\tresume: ReturnType<typeof vi.fn>\n\tcreateBiquadFilter: ReturnType<typeof vi.fn>\n\tcreateMediaElementSource: ReturnType<typeof vi.fn>\n}\n\nconst setupAudioContextMock = (state: AudioContextState = 'suspended') => {\n\tconst instances: MockAudioContext[] = []\n\tconst filters: MockFilter[] = []\n\tconst makeSource = () => ({\n\t\tconnect: vi.fn(),\n\t})\n\n\tclass AudioContextMock implements MockAudioContext {\n\t\tstate: AudioContextState = state\n\t\tdestination = { connect: vi.fn() }\n\n\t\tresume = vi.fn().mockImplementation(() => {\n\t\t\tthis.state = 'running'\n\n\t\t\treturn Promise.resolve()\n\t\t})\n\n\t\tcreateBiquadFilter = vi.fn(() => {\n\t\t\tconst filter: MockFilter = {\n\t\t\t\tconnect: vi.fn(),\n\t\t\t\ttype: 'peaking',\n\t\t\t\tfrequency: { value: 0 },\n\t\t\t\tQ: { value: 0 },\n\t\t\t\tgain: { value: 0 },\n\t\t\t}\n\t\t\tfilters.push(filter)\n\t\t\treturn filter\n\t\t})\n\n\t\tcreateMediaElementSource = vi.fn(() => makeSource())\n\n\t\tconstructor() {\n\t\t\tinstances.push(this)\n\t\t}\n\t}\n\n\tvi.stubGlobal('AudioContext', AudioContextMock)\n\n\treturn {\n\t\tAudioContextMock,\n\t\tinstances,\n\t\tfilters,\n\t}\n}\n\ndescribe('EqualizerStore', () => {\n\tit('does not create AudioContext in constructor and initializes lazily in resumeContext', async () => {\n\t\tconst { instances, filters } = setupAudioContextMock('suspended')\n\n\t\tconst store = new EqualizerStore({} as HTMLAudioElement)\n\t\texpect(instances).toHaveLength(0)\n\n\t\tawait store.resumeContext()\n\n\t\texpect(instances).toHaveLength(1)\n\t\texpect(instances[0]?.resume).toHaveBeenCalledTimes(1)\n\t\texpect(filters).toHaveLength(10)\n\t})\n\n\tit('reuses the same AudioContext and does not resume again once running', async () => {\n\t\tconst { instances } = setupAudioContextMock('suspended')\n\t\tconst store = new EqualizerStore({} as HTMLAudioElement)\n\n\t\tawait store.resumeContext()\n\t\tawait store.resumeContext()\n\n\t\texpect(instances).toHaveLength(1)\n\t\texpect(instances[0]?.resume).toHaveBeenCalledTimes(1)\n\t})\n\n\tit('does not call resume when context is already running', async () => {\n\t\tconst { instances } = setupAudioContextMock('running')\n\t\tconst store = new EqualizerStore({} as HTMLAudioElement)\n\n\t\tawait store.resumeContext()\n\n\t\texpect(instances[0]?.resume).not.toHaveBeenCalled()\n\t})\n\n\tit('setBand updates one band and clears selectedPreset', () => {\n\t\tsetupAudioContextMock('running')\n\t\tconst store = new EqualizerStore({} as HTMLAudioElement)\n\n\t\tstore.applyPreset('bassBoost')\n\t\tstore.setBand(0, 7)\n\n\t\texpect(store.bands[0]).toBe(7)\n\t\texpect(store.selectedPreset).toBeNull()\n\t})\n\n\tit('applyPreset and reset update bands and selectedPreset', () => {\n\t\tsetupAudioContextMock('running')\n\t\tconst store = new EqualizerStore({} as HTMLAudioElement)\n\n\t\tstore.applyPreset('trebleBoost')\n\t\texpect(store.selectedPreset).toBe('trebleBoost')\n\t\texpect(store.bands).toEqual([0, 0, 0, 0, 0, 0, 2, 4, 5, 6])\n\n\t\tstore.reset()\n\t\texpect(store.selectedPreset).toBe('flat')\n\t\texpect(store.bands).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])\n\t})\n})\n"
  },
  {
    "path": "src/lib/stores/player/__test__/player.svelte.test.ts",
    "content": "import 'fake-indexeddb/auto'\nimport { flushSync } from 'svelte'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getDatabase } from '$lib/db/database.ts'\nimport { clearDatabaseStores, expectToBeDefined } from '$lib/helpers/test-helpers.ts'\nimport { dbRemoveTracks } from '$lib/library/remove'\nimport { LEGACY_NO_NATIVE_DIRECTORY, type Track } from '$lib/library/types.ts'\nimport { PlayerStore } from '$lib/stores/player/player.svelte.ts'\n\nconst queryTracks = vi.hoisted(\n\t() =>\n\t\tnew Map<\n\t\t\tnumber,\n\t\t\t{\n\t\t\t\tid: number\n\t\t\t\tname: string\n\t\t\t\tartists: string[]\n\t\t\t\talbum: string\n\t\t\t\tfile: Track['file']\n\t\t\t\timage?: { full: Blob }\n\t\t\t}\n\t\t>(),\n)\n\nvi.mock('$lib/library/get/value-queries.ts', () => ({\n\tcreateTrackQuery: (idGetter: () => number) => ({\n\t\tget value() {\n\t\t\treturn queryTracks.get(idGetter())\n\t\t},\n\t\tget error() {\n\t\t\treturn undefined\n\t\t},\n\t\tget status() {\n\t\t\treturn 'loaded'\n\t\t},\n\t\tget loading() {\n\t\t\treturn false\n\t\t},\n\t}),\n}))\n\nvi.mock('$lib/stores/main/use-store.ts', () => ({\n\tuseMainStore: () => ({\n\t\tvolumeSliderEnabled: true,\n\t}),\n}))\n\nvi.mock('$lib/stores/player/equalizer.svelte.ts', () => ({\n\tEqualizerStore: class {\n\t\tinit() {}\n\t\tresumeContext() {\n\t\t\treturn Promise.resolve()\n\t\t}\n\t\tsetBand() {}\n\t\tapplyPreset() {}\n\t\treset() {}\n\t},\n}))\n\nconst createPlayerInRoot = () => {\n\tlet player: PlayerStore | undefined\n\tconst cleanup = $effect.root(() => {\n\t\tplayer = new PlayerStore()\n\t})\n\n\texpectToBeDefined(player)\n\n\treturn {\n\t\tplayer,\n\t\t[Symbol.dispose]: cleanup,\n\t}\n}\n\nclass MediaMetadataMock {}\n\nclass MockAudio {\n\tsrc = ''\n\tpaused = true\n\tcurrentTime = 0\n\tduration = 0\n\tvolume = 1\n\tplaybackRate = 1\n\tpreservesPitch = true\n\n\tonplay: (() => void) | null = null\n\tonpause: (() => void) | null = null\n\tonended: (() => void) | null = null\n\tondurationchange: (() => void) | null = null\n\tontimeupdate: (() => void) | null = null\n\n\tplay = vi.fn(() => {\n\t\tthis.paused = false\n\t\tthis.onplay?.()\n\n\t\treturn Promise.resolve()\n\t})\n\n\tpause = vi.fn(() => {\n\t\tthis.paused = true\n\t\tthis.onpause?.()\n\n\t\treturn Promise.resolve()\n\t})\n}\n\nconst getPlayHistoryEntries = async () => {\n\tconst db = await getDatabase()\n\treturn db.getAll('playHistory')\n}\n\nconst seedTrack = async (id: number) => {\n\tconst db = await getDatabase()\n\tconst trackData: Track = {\n\t\tid,\n\t\tuuid: `track-${id}`,\n\t\tname: `Track ${id}`,\n\t\tartists: ['Artist'],\n\t\talbum: 'Album',\n\t\tyear: '2026',\n\t\tduration: 180,\n\t\tgenre: [],\n\t\ttrackNo: 1,\n\t\ttrackOf: 1,\n\t\tdiscNo: 1,\n\t\tdiscOf: 1,\n\t\tfileName: `track-${id}.mp3`,\n\t\tdirectory: LEGACY_NO_NATIVE_DIRECTORY,\n\t\tscannedAt: Date.now(),\n\t\tfile: new File(['x'], `track-${id}.mp3`, { type: 'audio/mpeg' }),\n\t}\n\n\tawait db.add('tracks', trackData)\n\tqueryTracks.set(id, {\n\t\tid,\n\t\tname: trackData.name,\n\t\tartists: ['Artist'],\n\t\talbum: 'Album',\n\t\tfile: trackData.file,\n\t})\n}\n\ndescribe('PlayerStore', () => {\n\tdescribe('Play history', () => {\n\t\tlet mediaSession: {\n\t\t\tmetadata: MediaMetadata | null\n\t\t\tsetActionHandler: ReturnType<typeof vi.fn>\n\t\t}\n\t\tlet audioInstance: MockAudio | undefined\n\n\t\tconst audioWithCurrentTime = (time: number) => {\n\t\t\texpectToBeDefined(audioInstance)\n\t\t\taudioInstance.currentTime = time\n\t\t\taudioInstance.duration = 180\n\n\t\t\treturn audioInstance\n\t\t}\n\n\t\tbeforeEach(async () => {\n\t\t\tawait clearDatabaseStores()\n\n\t\t\tmediaSession = {\n\t\t\t\tmetadata: null,\n\t\t\t\tsetActionHandler: vi.fn(),\n\t\t\t}\n\t\t\taudioInstance = undefined\n\n\t\t\tclass AudioConstructor extends MockAudio {\n\t\t\t\tconstructor() {\n\t\t\t\t\tsuper()\n\t\t\t\t\taudioInstance = this\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvi.stubGlobal('Audio', AudioConstructor)\n\t\t\tvi.stubGlobal('MediaMetadata', MediaMetadataMock)\n\t\t\tvi.stubGlobal('navigator', {\n\t\t\t\tmediaSession,\n\t\t\t})\n\t\t\tvi.stubGlobal('window', {\n\t\t\t\tnavigator: {\n\t\t\t\t\tmediaSession,\n\t\t\t\t},\n\t\t\t})\n\t\t\tvi.stubGlobal('location', {\n\t\t\t\torigin: 'http://localhost',\n\t\t\t})\n\t\t})\n\n\t\tafterEach(async () => {\n\t\t\tawait clearDatabaseStores()\n\t\t\tqueryTracks.clear()\n\t\t\tvi.restoreAllMocks()\n\t\t\tvi.unstubAllGlobals()\n\t\t})\n\n\t\tit('saves final track to history when queue ends with repeat none', async () => {\n\t\t\tawait seedTrack(1)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [1])\n\t\t\texpectToBeDefined(audioInstance)\n\n\t\t\tconst audio = audioWithCurrentTime(120)\n\t\t\taudio.onended?.()\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries).toHaveLength(1)\n\n\t\t\texpect(entries[0]?.trackId).toBe(1)\n\t\t})\n\n\t\tit('does not save final track when played time is below threshold', async () => {\n\t\t\tawait seedTrack(2)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [2])\n\n\t\t\tconst audio = audioWithCurrentTime(10)\n\t\t\taudio.onended?.()\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries).toHaveLength(0)\n\t\t})\n\n\t\tit('does not save history on ended when repeat is one', async () => {\n\t\t\tawait seedTrack(4)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [4])\n\t\t\tflushSync()\n\n\t\t\tplayer.repeat = 'one'\n\t\t\tconst audio = audioWithCurrentTime(179)\n\t\t\taudio.onended?.()\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries).toHaveLength(0)\n\t\t})\n\n\t\tit('saves history when queue is cleared while playing current track', async () => {\n\t\t\tawait seedTrack(5)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [5])\n\t\t\tflushSync()\n\t\t\taudioWithCurrentTime(90)\n\n\t\t\tplayer.clearQueue()\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries[0]?.trackId).toBe(5)\n\t\t})\n\n\t\tit('saves history when currently playing track is removed from queue', async () => {\n\t\t\tawait seedTrack(6)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [6, 999])\n\t\t\tflushSync()\n\t\t\taudioWithCurrentTime(90)\n\n\t\t\tplayer.removeFromQueue(0)\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries).toHaveLength(1)\n\t\t\texpect(entries[0]?.trackId).toBe(6)\n\t\t})\n\n\t\tit('does not save history for track removed from library', async () => {\n\t\t\tawait seedTrack(7)\n\t\t\tawait seedTrack(8)\n\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\tplayer.playTrack(0, [7, 8, 999])\n\t\t\tflushSync()\n\t\t\taudioWithCurrentTime(90)\n\n\t\t\tawait dbRemoveTracks([7])\n\t\t\tflushSync()\n\n\t\t\tconst entries = await getPlayHistoryEntries()\n\t\t\texpect(entries).toHaveLength(0)\n\t\t})\n\t})\n\n\tdescribe('General behavior', () => {\n\t\tlet mediaSession: {\n\t\t\tmetadata: MediaMetadata | null\n\t\t\tsetActionHandler: ReturnType<typeof vi.fn>\n\t\t}\n\t\tlet audioInstance: MockAudio | undefined\n\n\t\tbeforeEach(() => {\n\t\t\tmediaSession = {\n\t\t\t\tmetadata: null,\n\t\t\t\tsetActionHandler: vi.fn(),\n\t\t\t}\n\t\t\taudioInstance = undefined\n\n\t\t\tclass AudioConstructor extends MockAudio {\n\t\t\t\tconstructor() {\n\t\t\t\t\tsuper()\n\t\t\t\t\taudioInstance = this\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvi.stubGlobal('Audio', AudioConstructor)\n\t\t\tvi.stubGlobal('MediaMetadata', MediaMetadataMock)\n\t\t\tvi.stubGlobal('navigator', {\n\t\t\t\tmediaSession,\n\t\t\t})\n\t\t\tvi.stubGlobal('window', {\n\t\t\t\tnavigator: {\n\t\t\t\t\tmediaSession,\n\t\t\t\t},\n\t\t\t})\n\t\t\tvi.stubGlobal('location', {\n\t\t\t\torigin: 'http://localhost',\n\t\t\t})\n\t\t})\n\n\t\tafterEach(() => {\n\t\t\tvi.restoreAllMocks()\n\t\t\tvi.unstubAllGlobals()\n\t\t})\n\n\t\tconst getMediaActionHandler = (\n\t\t\taction:\n\t\t\t\t| 'play'\n\t\t\t\t| 'pause'\n\t\t\t\t| 'previoustrack'\n\t\t\t\t| 'nexttrack'\n\t\t\t\t| 'seekbackward'\n\t\t\t\t| 'seekforward',\n\t\t) => {\n\t\t\tconst call = mediaSession.setActionHandler.mock.calls.find((c) => c[0] === action)\n\t\t\texpectToBeDefined(call)\n\t\t\tconst handler = call[1]\n\t\t\texpectToBeDefined(handler)\n\n\t\t\treturn handler\n\t\t}\n\n\t\tit('seekforward and seekbackward media actions clamp audio time', () => {\n\t\t\tcreatePlayerInRoot()\n\n\t\t\texpectToBeDefined(audioInstance)\n\n\t\t\taudioInstance.currentTime = 5\n\t\t\taudioInstance.duration = 15\n\n\t\t\tgetMediaActionHandler('seekbackward')()\n\t\t\texpect(audioInstance.currentTime).toBe(0)\n\n\t\t\tgetMediaActionHandler('seekforward')()\n\t\t\texpect(audioInstance.currentTime).toBe(10)\n\n\t\t\tgetMediaActionHandler('seekforward')()\n\t\t\texpect(audioInstance.currentTime).toBe(15)\n\t\t})\n\n\t\tit('toggleRepeat cycles', () => {\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\t\t\tplayer.repeat = 'none'\n\n\t\t\texpect(player.repeat).toBe('none')\n\t\t\tplayer.toggleRepeat()\n\t\t\texpect(player.repeat).toBe('all')\n\t\t\tplayer.toggleRepeat()\n\t\t\texpect(player.repeat).toBe('one')\n\t\t\tplayer.toggleRepeat()\n\t\t\texpect(player.repeat).toBe('none')\n\t\t})\n\n\t\tit('togglePlay does nothing when queue has no active track', () => {\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\n\t\t\texpect(player.playing).toBe(false)\n\t\t\tplayer.togglePlay(true)\n\t\t\texpect(player.playing).toBe(false)\n\t\t})\n\n\t\tit('seek updates player and audio currentTime', () => {\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\t\t\texpectToBeDefined(audioInstance)\n\n\t\t\tplayer.seek(42)\n\n\t\t\texpect(player.currentTime).toBe(42)\n\t\t\texpect(audioInstance.currentTime).toBe(42)\n\t\t})\n\n\t\tit('preservePitch updates audio pitch-preserve flags', () => {\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\t\t\texpectToBeDefined(audioInstance)\n\n\t\t\tplayer.preservePitch = false\n\t\t\tflushSync()\n\n\t\t\texpect(audioInstance.preservesPitch).toBe(false)\n\t\t})\n\n\t\tit('playTrack on same active track seeks to start', async () => {\n\t\t\tawait seedTrack(3)\n\t\t\tusing pl = createPlayerInRoot()\n\t\t\tconst { player } = pl\n\t\t\tplayer.playTrack(0, [3])\n\t\t\tflushSync()\n\n\t\t\texpectToBeDefined(audioInstance)\n\n\t\t\taudioInstance.currentTime = 99\n\t\t\tplayer.playTrack(0)\n\n\t\t\texpect(player.currentTime).toBe(0)\n\t\t\texpect(audioInstance.currentTime).toBe(0)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/stores/player/__test__/queue.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { QueueStore } from '$lib/stores/player/queue.svelte.ts'\n\nconst track = (n: number) => n\n\ndescribe('QueueStore', () => {\n\tdescribe('setTrack', () => {\n\t\tit('sets queue and active index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [10, 20, 30])\n\t\t\texpect(q.itemsIds).toEqual([10, 20, 30])\n\t\t\texpect(q.activeTrackIndex).toBe(1)\n\t\t})\n\n\t\tit('sets active index to -1 for empty queue', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [])\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t\texpect(q.isQueueEmpty).toBe(true)\n\t\t})\n\n\t\tit('shuffles and pins active index to 0 when shuffle option is set', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [1, 2, 3, 4, 5], { shuffle: true })\n\t\t\texpect(q.shuffle).toBe(true)\n\t\t\texpect(q.activeTrackIndex).toBe(0)\n\t\t\texpect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([1, 2, 3, 4, 5])\n\t\t})\n\n\t\tit('disables shuffle when new queue is passed without shuffle option', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [1, 2], { shuffle: true })\n\t\t\tq.setTrack(0, [3, 4])\n\t\t\texpect(q.shuffle).toBe(false)\n\t\t})\n\n\t\tit('changes only active index when no new queue is given', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.setTrack(2)\n\t\t\texpect(q.itemsIds).toEqual([10, 20, 30])\n\t\t\texpect(q.activeTrackIndex).toBe(2)\n\t\t})\n\t})\n\n\tdescribe('getNextIndex / getPrevIndex', () => {\n\t\tit('returns next index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [track(1), track(2), track(3)])\n\t\t\texpect(q.getNextIndex()).toBe(2)\n\t\t})\n\n\t\tit('wraps to 0 at the end', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(2, [track(1), track(2), track(3)])\n\t\t\texpect(q.getNextIndex()).toBe(0)\n\t\t})\n\n\t\tit('returns prev index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(2, [track(1), track(2), track(3)])\n\t\t\texpect(q.getPrevIndex()).toBe(1)\n\t\t})\n\n\t\tit('wraps to last at the start', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [track(1), track(2), track(3)])\n\t\t\texpect(q.getPrevIndex()).toBe(2)\n\t\t})\n\t})\n\n\tdescribe('toggleShuffle', () => {\n\t\tit('enables shuffle and moves active track to index 0', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [10, 20, 30])\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.shuffle).toBe(true)\n\t\t\texpect(q.activeTrackIndex).toBe(0)\n\t\t\texpect(q.itemsIds[0]).toBe(20)\n\t\t})\n\n\t\tit('shuffled list contains all original IDs', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30, 40, 50])\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([10, 20, 30, 40, 50])\n\t\t})\n\n\t\tit('disables shuffle and restores original order with correct active index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [10, 20, 30])\n\t\t\tq.toggleShuffle()\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.shuffle).toBe(false)\n\t\t\texpect(q.itemsIds).toEqual([10, 20, 30])\n\t\t\texpect(q.activeTrackIndex).toBe(1)\n\t\t})\n\n\t\tit('preserves the active track ID when disabling after navigating in shuffle mode', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.toggleShuffle()\n\t\t\t// Navigate to the second position in the shuffled list\n\t\t\tconst navigatedId = q.itemsIds[1] as number\n\t\t\tq.setTrack(1)\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.activeTrackId).toBe(navigatedId)\n\t\t\texpect(q.itemsIds).toEqual([10, 20, 30])\n\t\t})\n\n\t\tit('sets active index to -1 when enabling with no active track', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.removeFromQueue(0) // removes the active track → index becomes -1\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.shuffle).toBe(true)\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t\texpect(q.itemsIds.toSorted((a, b) => a - b)).toEqual([20, 30])\n\t\t})\n\n\t\tit('toggles gracefully on an empty queue', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.shuffle).toBe(true)\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t\texpect(q.itemsIds).toEqual([])\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.shuffle).toBe(false)\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t})\n\t})\n\n\tdescribe('addToQueue', () => {\n\t\tit('appends a single track', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [1, 2])\n\t\t\tq.addToQueue(3)\n\t\t\texpect(q.itemsIds).toEqual([1, 2, 3])\n\t\t})\n\n\t\tit('appends multiple tracks', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [1])\n\t\t\tq.addToQueue([2, 3])\n\t\t\texpect(q.itemsIds).toEqual([1, 2, 3])\n\t\t})\n\n\t\tit('activates index 0 when queue was empty', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t\tq.addToQueue(5)\n\t\t\texpect(q.activeTrackIndex).toBe(0)\n\t\t})\n\n\t\tit('while shuffled, added track is visible immediately and survives toggle-off', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20])\n\t\t\tq.toggleShuffle()\n\t\t\tq.addToQueue(30)\n\t\t\texpect(q.itemsIds).toContain(30)\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.itemsIds).toContain(30)\n\t\t})\n\t})\n\n\tdescribe('removeFromQueue', () => {\n\t\tit('removes a track by index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.removeFromQueue(1)\n\t\t\texpect(q.itemsIds).toEqual([10, 30])\n\t\t})\n\n\t\tit('decrements active index when removing before it', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(2, [10, 20, 30])\n\t\t\tq.removeFromQueue(0)\n\t\t\texpect(q.activeTrackIndex).toBe(1)\n\t\t})\n\n\t\tit('sets active index to -1 when removing the active track', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [10, 20, 30])\n\t\t\tq.removeFromQueue(1)\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t})\n\n\t\tit('does not change active index when removing after it', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.removeFromQueue(2)\n\t\t\texpect(q.activeTrackIndex).toBe(0)\n\t\t})\n\n\t\tit('ignores out-of-bounds index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20])\n\t\t\tq.removeFromQueue(5)\n\t\t\texpect(q.itemsIds).toEqual([10, 20])\n\t\t})\n\n\t\tit('removes from both lists when shuffle is enabled', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(0, [10, 20, 30])\n\t\t\tq.toggleShuffle()\n\n\t\t\tconst removedId = q.itemsIds[1] as number\n\t\t\tq.removeFromQueue(1)\n\t\t\texpect(q.itemsIds).not.toContain(removedId)\n\n\t\t\tq.toggleShuffle()\n\t\t\texpect(q.itemsIds).not.toContain(removedId)\n\t\t})\n\t})\n\n\tdescribe('clearQueue', () => {\n\t\tit('empties the queue and resets active index', () => {\n\t\t\tconst q = new QueueStore()\n\t\t\tq.setTrack(1, [1, 2, 3])\n\t\t\tq.clearQueue()\n\t\t\texpect(q.itemsIds).toEqual([])\n\t\t\texpect(q.activeTrackIndex).toBe(-1)\n\t\t\texpect(q.isQueueEmpty).toBe(true)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "src/lib/stores/player/audio-loader.svelte.ts",
    "content": "import { getDatabase } from '$lib/db/database'\nimport type { FileEntity } from '$lib/helpers/file-system'\nimport { isAndroid, isChromiumBased } from '$lib/helpers/utils/ua'\n\nconst requestPermission = async (handle: FileSystemHandle) => {\n\tlet mode = await handle.queryPermission({ mode: 'read' })\n\n\tif (mode === 'prompt') {\n\t\ttry {\n\t\t\tmode = await handle.requestPermission({ mode: 'read' })\n\t\t} catch {\n\t\t\t// `requestPermission` requires a user activation; so swallow the error\n\t\t\t// and treat it as a denial of permission.\n\t\t}\n\t}\n\n\tif (mode === 'granted') {\n\t\treturn 'granted'\n\t}\n\n\treturn 'denied'\n}\n\nconst getTrackFileRegular = async (entity: FileSystemFileHandle): Promise<File | null> => {\n\tconst permission = await requestPermission(entity)\n\tif (permission === 'denied') {\n\t\treturn null\n\t}\n\n\treturn entity.getFile()\n}\n\nconst getTrackFileWorkaroundForAndroid = async (directoryId: number, fileName: string) => {\n\tconst db = await getDatabase()\n\tconst dir = await db.get('directories', directoryId)\n\tif (!dir) {\n\t\treturn null\n\t}\n\n\tconst permission = await requestPermission(dir.handle)\n\tif (permission === 'denied') {\n\t\treturn null\n\t}\n\n\tconst fileHandle = await dir.handle.getFileHandle(fileName)\n\n\treturn fileHandle.getFile()\n}\n\nconst getTrackFile = async (directoryId: number, entity: FileEntity) => {\n\ttry {\n\t\tlet trackFile: File | null = null\n\n\t\tif (entity instanceof File) {\n\t\t\ttrackFile = entity\n\t\t}\n\t\t// Android on Chromium based browsers has a regression where persisted FileSystemFileHandles\n\t\t// fail with net:ERR_FILE_NOT_FOUND when used with URL.createObjectURL.\n\t\t// https://issues.chromium.org/issues/499064852\n\t\telse if (isAndroid() && isChromiumBased()) {\n\t\t\ttrackFile = await getTrackFileWorkaroundForAndroid(directoryId, entity.name)\n\t\t} else {\n\t\t\ttrackFile = await getTrackFileRegular(entity)\n\t\t}\n\n\t\tif (trackFile) {\n\t\t\treturn { status: 'loaded', file: trackFile } as const\n\t\t}\n\n\t\treturn { status: 'permission-denied' } as const\n\t} catch (error) {\n\t\tif (error instanceof DOMException && error.name === 'NotFoundError') {\n\t\t\treturn { status: 'not-found' } as const\n\t\t}\n\n\t\tconsole.error('Error loading track file:', error)\n\n\t\treturn { status: 'error' } as const\n\t}\n}\n\nexport class AudioLoader {\n\tloading: boolean = $state(false)\n\n\t#onSrc: (src: string | null) => void\n\t#currentSrc: string | null = null\n\t#current = 0\n\n\tconstructor(onSrc: (src: string | null) => void) {\n\t\tthis.#onSrc = onSrc\n\t}\n\n\tload = async (directoryId: number, file: FileEntity) => {\n\t\tthis.#current += 1\n\t\tconst gen = this.#current\n\t\tthis.loading = true\n\t\tthis.#clearSrc()\n\n\t\tconst { status: trackStatus, file: trackFile } = await getTrackFile(directoryId, file)\n\t\tif (this.#current !== gen) {\n\t\t\treturn { status: 'superseded' } as const\n\t\t}\n\n\t\tif (trackStatus !== 'loaded') {\n\t\t\tthis.loading = false\n\n\t\t\treturn { status: 'failed', reason: trackStatus } as const\n\t\t}\n\n\t\tthis.#currentSrc = URL.createObjectURL(trackFile)\n\t\tthis.#onSrc(this.#currentSrc)\n\t\tthis.loading = false\n\t\treturn { status: 'loaded' } as const\n\t}\n\n\treset = (): void => {\n\t\tthis.#current += 1\n\t\tthis.#clearSrc()\n\t\tthis.loading = false\n\t}\n\n\t#clearSrc = (): void => {\n\t\tif (this.#currentSrc) {\n\t\t\tURL.revokeObjectURL(this.#currentSrc)\n\t\t\tthis.#currentSrc = null\n\t\t}\n\t\tthis.#onSrc(null)\n\t}\n}\n"
  },
  {
    "path": "src/lib/stores/player/equalizer.svelte.ts",
    "content": "import { persist } from '$lib/helpers/persist.svelte.ts'\n\nexport const EQ_BANDS = [\n\t{ frequency: 32, label: '32 Hz' },\n\t{ frequency: 64, label: '64 Hz' },\n\t{ frequency: 125, label: '125 Hz' },\n\t{ frequency: 250, label: '250 Hz' },\n\t{ frequency: 500, label: '500 Hz' },\n\t{ frequency: 1000, label: '1 kHz' },\n\t{ frequency: 2000, label: '2 kHz' },\n\t{ frequency: 4000, label: '4 kHz' },\n\t{ frequency: 8000, label: '8 kHz' },\n\t{ frequency: 16_000, label: '16 kHz' },\n] as const\n\nexport type BuiltinEqPresetKey =\n\t| 'flat'\n\t| 'bassBoost'\n\t| 'trebleBoost'\n\t| 'rock'\n\t| 'pop'\n\t| 'jazz'\n\t| 'classical'\n\t| 'electronic'\n\t| 'acoustic'\n\nconst EQ_PRESET_GAINS: Record<BuiltinEqPresetKey, readonly number[]> = {\n\tflat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n\tbassBoost: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0],\n\ttrebleBoost: [0, 0, 0, 0, 0, 0, 2, 4, 5, 6],\n\trock: [4, 3, 1, 0, -1, 0, 1, 3, 4, 4],\n\tpop: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2],\n\tjazz: [3, 2, 0, 0, 1, 2, 2, 1, 2, 3],\n\tclassical: [0, 0, 0, 1, 2, 2, 1, 2, 3, 4],\n\telectronic: [5, 4, 2, 0, 1, 2, 1, 3, 4, 4],\n\tacoustic: [2, 1, 0, 1, 2, 2, 1, 2, 2, 1],\n}\n\nexport const EQ_MIN_GAIN = -12\nexport const EQ_MAX_GAIN = 12\n\nexport class EqualizerStore {\n\tenabled: boolean = $state(false)\n\tbands: number[] = $state([...EQ_PRESET_GAINS.flat])\n\tselectedPreset: BuiltinEqPresetKey | null = $state('flat')\n\n\treadonly #audio: HTMLAudioElement\n\t#audioContext: AudioContext | null = null\n\t#filters: BiquadFilterNode[] = []\n\n\tconstructor(audio: HTMLAudioElement) {\n\t\tthis.#audio = audio\n\t}\n\n\tinit = (): void => {\n\t\tpersist('equalizer', this, ['enabled', 'bands', 'selectedPreset'])\n\n\t\t$effect(() => {\n\t\t\tconst enabled = this.enabled\n\t\t\tconst bands = this.bands\n\t\t\tif (this.#filters.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinvariant(this.#filters.length === bands.length)\n\n\t\t\tfor (const [index, filter] of this.#filters.entries()) {\n\t\t\t\tfilter.gain.value = enabled ? (bands[index] ?? 0) : 0\n\t\t\t}\n\t\t})\n\t}\n\n\t#ensureAudioGraph = (): AudioContext => {\n\t\tif (this.#audioContext !== null) {\n\t\t\treturn this.#audioContext\n\t\t}\n\n\t\tconst audioContext = new AudioContext()\n\t\tconst filters = EQ_BANDS.map(({ frequency }) => {\n\t\t\tconst filter = audioContext.createBiquadFilter()\n\t\t\tfilter.type = 'peaking'\n\t\t\tfilter.frequency.value = frequency\n\t\t\tfilter.Q.value = 1.41\n\t\t\tfilter.gain.value = 0\n\n\t\t\treturn filter\n\t\t})\n\n\t\tconst source = audioContext.createMediaElementSource(this.#audio)\n\n\t\t// Chain filters\n\t\tlet node: AudioNode = source\n\t\tfor (const filter of filters) {\n\t\t\tnode.connect(filter)\n\t\t\tnode = filter\n\t\t}\n\t\tnode.connect(audioContext.destination)\n\n\t\tthis.#audioContext = audioContext\n\t\tthis.#filters = filters\n\n\t\treturn audioContext\n\t}\n\n\tresumeContext = (): Promise<void> => {\n\t\tconst audioContext = this.#ensureAudioGraph()\n\t\tif (audioContext.state === 'suspended') {\n\t\t\treturn audioContext.resume()\n\t\t}\n\n\t\treturn Promise.resolve()\n\t}\n\n\tsetBand = (index: number, gain: number): void => {\n\t\tthis.bands[index] = gain\n\t\tthis.selectedPreset = null\n\t}\n\n\tapplyPreset = (name: BuiltinEqPresetKey): void => {\n\t\tthis.bands = [...EQ_PRESET_GAINS[name]]\n\t\tthis.selectedPreset = name\n\t}\n\n\treset = (): void => {\n\t\tthis.applyPreset('flat')\n\t}\n}\n"
  },
  {
    "path": "src/lib/stores/player/player.svelte.ts",
    "content": "import type { QueryResult } from '$lib/db/query/query.ts'\nimport { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte'\nimport { persist } from '$lib/helpers/persist.svelte.ts'\nimport { clamp } from '$lib/helpers/utils/clamp.ts'\nimport { debounce } from '$lib/helpers/utils/debounce.ts'\nimport { formatArtists, truncate } from '$lib/helpers/utils/text.ts'\nimport { throttle } from '$lib/helpers/utils/throttle.ts'\nimport { createTrackQuery, type TrackData } from '$lib/library/get/value-queries.ts'\nimport { dbAddToPlayHistory } from '$lib/library/play-history-actions.ts'\nimport { AudioLoader } from './audio-loader.svelte.ts'\nimport { EqualizerStore } from './equalizer.svelte.ts'\nimport { type PlayTrackOptions, QueueStore } from './queue.svelte.ts'\n\nexport type { PlayTrackOptions }\n\nexport type PlayerRepeat = 'none' | 'one' | 'all'\n\nexport const PLAYER_PLAYBACK_RATE_MIN = 0.5\nexport const PLAYER_PLAYBACK_RATE_MAX = 2\n\nexport class PlayerStore {\n\treadonly #main = useMainStore()\n\n\treadonly #audio = new Audio()\n\treadonly #audioLoader = new AudioLoader((src) => {\n\t\tthis.#audio.src = src ?? ''\n\t})\n\treadonly #queue = new QueueStore()\n\treadonly equalizer = new EqualizerStore(this.#audio)\n\n\trepeat: PlayerRepeat = $state('none')\n\tplaying: boolean = $state(false)\n\tmuted: boolean = $state(false)\n\t#volume: number = $state(100)\n\n\tplaybackRate: number = $state(1)\n\tpreservePitch: boolean = $state(true)\n\n\tget shuffle(): boolean {\n\t\treturn this.#queue.shuffle\n\t}\n\n\tget itemsIds(): readonly number[] {\n\t\treturn this.#queue.itemsIds\n\t}\n\n\tget activeTrackIndex(): number {\n\t\treturn this.#queue.activeTrackIndex\n\t}\n\n\tget isQueueEmpty(): boolean {\n\t\treturn this.#queue.isQueueEmpty\n\t}\n\n\tloading: boolean = $derived(this.#audioLoader.loading)\n\n\tcurrentTime: number = $state(0)\n\tduration: number = $state(0)\n\n\tget volume(): number {\n\t\treturn this.#main.volumeSliderEnabled ? this.#volume : 100\n\t}\n\n\tset volume(value: number) {\n\t\tthis.#volume = clamp(value, 0, 100)\n\t}\n\n\t#activeTrackQuery: QueryResult<TrackData | undefined> = createTrackQuery(\n\t\t() => this.#queue.itemsIds[this.#queue.activeTrackIndex] ?? -1,\n\t\t{ allowEmpty: true },\n\t)\n\n\tactiveTrack: TrackData | undefined = $derived(this.#activeTrackQuery.value)\n\n\t#artwork = createManagedArtwork(() => this.activeTrack?.image?.full)\n\tartworkSrc: string | undefined = $derived.by(this.#artwork)\n\n\tconstructor() {\n\t\tpersist('player', this, ['volume', 'repeat', 'muted', 'playbackRate', 'preservePitch'])\n\t\tpersist('player', this.#queue, ['shuffle'])\n\n\t\tthis.equalizer.init()\n\n\t\tconst audio = this.#audio\n\n\t\t// Plain (non-$state) so reads inside the effect don't create subscriptions.\n\t\tlet prevTrackId: number | null = null\n\n\t\t// Debounced to recover from transient undefined during a DB refresh.\n\t\tconst scheduleAudioReset = debounce(() => {\n\t\t\tif (!this.activeTrack) {\n\t\t\t\tthis.#audioLoader.reset()\n\t\t\t\tthis.currentTime = 0\n\t\t\t\tthis.duration = 0\n\t\t\t\tthis.playing = false\n\t\t\t}\n\t\t}, 100)\n\n\t\tconst trackChanged = (track: TrackData | undefined) => {\n\t\t\tif (!track) {\n\t\t\t\tif (prevTrackId !== null) {\n\t\t\t\t\tthis.#savePlayHistory(prevTrackId)\n\n\t\t\t\t\tprevTrackId = null\n\t\t\t\t}\n\t\t\t\tscheduleAudioReset()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (track.id === prevTrackId) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tscheduleAudioReset.cancel()\n\n\t\t\tif (prevTrackId !== null) {\n\t\t\t\tthis.#savePlayHistory(prevTrackId)\n\t\t\t}\n\n\t\t\tprevTrackId = track.id\n\t\t\tthis.currentTime = 0\n\t\t\tthis.duration = 0\n\n\t\t\tvoid this.#audioLoader.load(track.directory, track.file).then((result) => {\n\t\t\t\tif (result.status === 'failed') {\n\t\t\t\t\tconst name = truncate(track.name, 30)\n\t\t\t\t\tconst errorMap = {\n\t\t\t\t\t\t'not-found': m.playerAudioErrorNotFound,\n\t\t\t\t\t\t'permission-denied': m.playerAudioErrorPermissionDenied,\n\t\t\t\t\t\terror: m.playerAudioErrorLoadError,\n\t\t\t\t\t}\n\n\t\t\t\t\tsnackbar({\n\t\t\t\t\t\tmessage: errorMap[result.reason]({ name }),\n\t\t\t\t\t\tid: 'failed-to-load-audio',\n\t\t\t\t\t\tduration: 10_000,\n\t\t\t\t\t})\n\n\t\t\t\t\tprevTrackId = null\n\t\t\t\t\tthis.#queue.setTrack(-1)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\t$effect(() => {\n\t\t\tconst track = this.activeTrack\n\n\t\t\tuntrack(() => {\n\t\t\t\ttrackChanged(track)\n\t\t\t})\n\t\t})\n\n\t\t// Guarded by loading: prevents play() on an empty/stale src during file fetch.\n\t\t$effect(() => {\n\t\t\tif (this.#audioLoader.loading) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst shouldPlay = this.playing\n\n\t\t\tif (audio.paused === !shouldPlay) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (shouldPlay) {\n\t\t\t\tvoid this.equalizer.resumeContext().then(() => audio.play())\n\t\t\t} else {\n\t\t\t\tvoid audio.pause()\n\t\t\t}\n\t\t})\n\n\t\tconst syncPlayingFromAudio = () => {\n\t\t\tconst audioPlaying = !audio.paused\n\t\t\tif (audioPlaying !== this.playing) {\n\t\t\t\tthis.playing = audioPlaying\n\t\t\t}\n\t\t}\n\n\t\taudio.onplay = syncPlayingFromAudio\n\t\taudio.onpause = syncPlayingFromAudio\n\n\t\taudio.onended = () => {\n\t\t\tif (this.repeat === 'one') {\n\t\t\t\tthis.seek(0)\n\t\t\t\tthis.togglePlay(true)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tthis.repeat === 'none' &&\n\t\t\t\tthis.#queue.activeTrackIndex === this.#queue.itemsIds.length - 1\n\t\t\t) {\n\t\t\t\tconst trackId = this.#queue.activeTrackId\n\t\t\t\tif (trackId !== null) {\n\t\t\t\t\tthis.#savePlayHistory(trackId)\n\t\t\t\t}\n\n\t\t\t\tthis.togglePlay(false)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.playNext()\n\t\t}\n\n\t\taudio.ondurationchange = () => {\n\t\t\tthis.duration = audio.duration\n\t\t}\n\n\t\taudio.ontimeupdate = throttle(() => {\n\t\t\tthis.currentTime = audio.currentTime\n\t\t}, 250)\n\n\t\tconst setPlaybackRate = () => {\n\t\t\taudio.playbackRate = clamp(\n\t\t\t\tthis.playbackRate,\n\t\t\t\tPLAYER_PLAYBACK_RATE_MIN,\n\t\t\t\tPLAYER_PLAYBACK_RATE_MAX,\n\t\t\t)\n\t\t}\n\n\t\taudio.onloadedmetadata = () => {\n\t\t\t// Audio change resets playbackRate\n\t\t\tsetPlaybackRate()\n\t\t}\n\n\t\t$effect(() => {\n\t\t\tsetPlaybackRate()\n\t\t})\n\n\t\t$effect(() => {\n\t\t\taudio.preservesPitch = this.preservePitch\n\t\t})\n\n\t\t$effect(() => {\n\t\t\t// Humans perceive volume logarithmically\n\t\t\t// so we adjust the volume to match that perception\n\t\t\tconst k = 0.5\n\t\t\taudio.volume = (this.volume / 100) ** k\n\t\t})\n\n\t\t$effect(() => {\n\t\t\taudio.muted = this.muted\n\t\t})\n\n\t\tconst ms = window.navigator.mediaSession\n\n\t\t$effect(() => {\n\t\t\tconst track = this.activeTrack\n\t\t\tif (!track) {\n\t\t\t\tms.metadata = null\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst fallbackArtworkSrc = new URL('/artwork.svg', location.origin).toString()\n\t\t\tms.metadata = new MediaMetadata({\n\t\t\t\ttitle: track.name,\n\t\t\t\tartist: formatArtists(track.artists),\n\t\t\t\talbum: track.album,\n\t\t\t\tartwork: [\n\t\t\t\t\t{\n\t\t\t\t\t\tsrc: this.artworkSrc ?? fallbackArtworkSrc,\n\t\t\t\t\t\tsizes: '512x512',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t})\n\t\t})\n\n\t\t// Done for minification purposes.\n\t\tconst setAction = ms.setActionHandler.bind(ms)\n\t\tsetAction('play', () => this.togglePlay(true))\n\t\tsetAction('pause', () => this.togglePlay(false))\n\t\tsetAction('previoustrack', this.playPrev)\n\t\tsetAction('nexttrack', this.playNext)\n\t\tsetAction('seekbackward', () => {\n\t\t\taudio.currentTime = Math.max(audio.currentTime - 10, 0)\n\t\t})\n\t\tsetAction('seekforward', () => {\n\t\t\taudio.currentTime = Math.min(audio.currentTime + 10, audio.duration)\n\t\t})\n\t\t// seekto is handled by AudioElement default behavior\n\t}\n\n\t#savePlayHistory = (trackId: number): void => {\n\t\tconst playedTime = this.#audio.currentTime\n\t\tconst totalDuration = this.#audio.duration\n\n\t\tconst percentageThreshold = 0.5\n\t\tconst timeThreshold = 30\n\n\t\tconst threshold = Math.min(timeThreshold, totalDuration * percentageThreshold)\n\t\tif (totalDuration > 0 && playedTime >= threshold) {\n\t\t\tvoid dbAddToPlayHistory(trackId)\n\t\t}\n\t}\n\n\ttogglePlay = (force?: boolean): void => {\n\t\tif (this.#queue.activeTrackIndex === -1) {\n\t\t\treturn\n\t\t}\n\n\t\tthis.playing = force ?? !this.playing\n\t}\n\n\tplayNext = (): void => {\n\t\tthis.playTrack(this.#queue.getNextIndex())\n\t}\n\n\tplayPrev = (): void => {\n\t\tthis.playTrack(this.#queue.getPrevIndex())\n\t}\n\n\tplayTrack = (\n\t\ttrackIndex: number,\n\t\tqueue?: readonly number[],\n\t\toptions: PlayTrackOptions = {},\n\t): void => {\n\t\tconst currentTrackId = this.#queue.activeTrackId\n\t\tthis.#queue.setTrack(trackIndex, queue, options)\n\n\t\tconst isSameTrack = currentTrackId !== null && this.#queue.activeTrackId === currentTrackId\n\n\t\tif (isSameTrack) {\n\t\t\t// Reset time to 0\n\t\t\tthis.seek(0)\n\t\t} else {\n\t\t\t// Update ui time instantly, but keep audio.currentTime\n\t\t\t// until play history is saved.\n\t\t\tthis.currentTime = 0\n\t\t}\n\n\t\tthis.togglePlay(true)\n\t}\n\n\tseek = (time: number): void => {\n\t\tthis.currentTime = time\n\t\tthis.#audio.currentTime = time\n\t}\n\n\ttoggleRepeat = (): void => {\n\t\tlet { repeat } = this\n\n\t\tif (repeat === 'none') {\n\t\t\trepeat = 'all'\n\t\t} else if (repeat === 'all') {\n\t\t\trepeat = 'one'\n\t\t} else {\n\t\t\trepeat = 'none'\n\t\t}\n\n\t\tthis.repeat = repeat\n\t}\n\n\ttoggleShuffle = this.#queue.toggleShuffle\n\n\taddToQueue = this.#queue.addToQueue\n\n\tremoveFromQueue = this.#queue.removeFromQueue\n\n\tmoveQueueItem = this.#queue.moveQueueItem\n\n\tclearQueue = this.#queue.clearQueue\n}\n"
  },
  {
    "path": "src/lib/stores/player/queue.svelte.ts",
    "content": "import { onDatabaseChange } from '$lib/db/events.ts'\nimport { toShuffledArray } from '$lib/helpers/utils/array.ts'\n\nexport interface PlayTrackOptions {\n\tshuffle?: boolean\n}\n\nexport class QueueStore {\n\tshuffle: boolean = $state(false)\n\n\t#activeTrackIndex = $state(-1)\n\t#itemsIdsOriginalOrder = $state<number[]>([])\n\t#itemsIdsShuffled = $state<number[] | null>(null)\n\n\titemsIds: readonly number[] = $derived(\n\t\tthis.#itemsIdsShuffled ? this.#itemsIdsShuffled : this.#itemsIdsOriginalOrder,\n\t)\n\n\tget activeTrackIndex(): number {\n\t\treturn this.#activeTrackIndex\n\t}\n\n\tget activeTrackId(): number | null {\n\t\treturn this.itemsIds[this.#activeTrackIndex] ?? null\n\t}\n\n\tget isQueueEmpty(): boolean {\n\t\treturn this.itemsIds.length === 0\n\t}\n\n\tconstructor() {\n\t\tonDatabaseChange((changes) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName !== 'tracks' || change.operation !== 'delete') {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst index = this.itemsIds.indexOf(change.key)\n\t\t\t\tif (index !== -1) {\n\t\t\t\t\tthis.#removeByIndex(index, change.key)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\tsetTrack = (\n\t\ttrackIndex: number,\n\t\tnewQueue?: readonly number[],\n\t\toptions: PlayTrackOptions = {},\n\t): void => {\n\t\tif (newQueue) {\n\t\t\tthis.#itemsIdsOriginalOrder = [...newQueue]\n\t\t\tthis.shuffle = options.shuffle ?? false\n\n\t\t\tif (this.shuffle) {\n\t\t\t\tthis.#itemsIdsShuffled = toShuffledArray(this.#itemsIdsOriginalOrder)\n\t\t\t} else {\n\t\t\t\tthis.#itemsIdsShuffled = null\n\t\t\t}\n\t\t}\n\n\t\tif (this.itemsIds.length === 0) {\n\t\t\tthis.#activeTrackIndex = -1\n\t\t} else {\n\t\t\tthis.#activeTrackIndex = options.shuffle ? 0 : trackIndex\n\t\t}\n\t}\n\n\tgetNextIndex = (): number => {\n\t\tconst next = this.#activeTrackIndex + 1\n\t\treturn next >= this.itemsIds.length ? 0 : next\n\t}\n\n\tgetPrevIndex = (): number => {\n\t\tconst prev = this.#activeTrackIndex - 1\n\t\treturn prev < 0 ? this.itemsIds.length - 1 : prev\n\t}\n\n\ttoggleShuffle = (): void => {\n\t\tconst activeTrackId = this.itemsIds[this.#activeTrackIndex] ?? -1\n\t\tthis.shuffle = !this.shuffle\n\n\t\tif (this.shuffle) {\n\t\t\tthis.#itemsIdsShuffled = toShuffledArray(this.#itemsIdsOriginalOrder)\n\n\t\t\tconst newIndex = this.#itemsIdsShuffled.indexOf(activeTrackId)\n\t\t\tif (newIndex === -1) {\n\t\t\t\tthis.#activeTrackIndex = -1\n\t\t\t} else {\n\t\t\t\tconst displaced = this.#itemsIdsShuffled[0] as number\n\t\t\t\tthis.#itemsIdsShuffled[0] = activeTrackId\n\t\t\t\tthis.#itemsIdsShuffled[newIndex] = displaced\n\t\t\t\tthis.#activeTrackIndex = 0\n\t\t\t}\n\t\t} else {\n\t\t\tthis.#itemsIdsShuffled = null\n\t\t\tthis.#activeTrackIndex = this.#itemsIdsOriginalOrder.indexOf(activeTrackId)\n\t\t}\n\t}\n\n\taddToQueue = (trackId: number | readonly number[]): void => {\n\t\tconst ids: readonly number[] = Array.isArray(trackId) ? trackId : [trackId]\n\t\tthis.#itemsIdsShuffled?.push(...ids)\n\t\tthis.#itemsIdsOriginalOrder.push(...ids)\n\n\t\tif (this.#activeTrackIndex === -1) {\n\t\t\tthis.#activeTrackIndex = 0\n\t\t}\n\t}\n\n\tremoveFromQueue = (index: number): void => {\n\t\tif (index < 0 || index >= this.itemsIds.length) {\n\t\t\treturn\n\t\t}\n\n\t\tconst trackId = this.itemsIds[index]\n\t\tinvariant(trackId !== undefined)\n\t\tthis.#removeByIndex(index, trackId)\n\t}\n\n\tclearQueue = (): void => {\n\t\tthis.#itemsIdsOriginalOrder = []\n\t\tthis.#itemsIdsShuffled = null\n\t\tthis.#activeTrackIndex = -1\n\t}\n\n\tmoveQueueItem = (fromIndex: number, toIndex: number): void => {\n\t\tif (\n\t\t\tfromIndex < 0 ||\n\t\t\tfromIndex >= this.itemsIds.length ||\n\t\t\ttoIndex < 0 ||\n\t\t\ttoIndex >= this.itemsIds.length ||\n\t\t\tfromIndex === toIndex\n\t\t) {\n\t\t\treturn\n\t\t}\n\n\t\t// Manual reorder uses the currently visible order as source of truth.\n\t\tif (this.#itemsIdsShuffled) {\n\t\t\tthis.#itemsIdsOriginalOrder = [...this.#itemsIdsShuffled]\n\t\t\tthis.#itemsIdsShuffled = null\n\t\t\tthis.shuffle = false\n\t\t}\n\n\t\tconst movedTrackId = this.#itemsIdsOriginalOrder[fromIndex]\n\t\tif (movedTrackId === undefined) {\n\t\t\treturn\n\t\t}\n\n\t\tthis.#itemsIdsOriginalOrder.splice(fromIndex, 1)\n\t\tthis.#itemsIdsOriginalOrder.splice(toIndex, 0, movedTrackId)\n\n\t\tif (this.#activeTrackIndex === fromIndex) {\n\t\t\tthis.#activeTrackIndex = toIndex\n\t\t\treturn\n\t\t}\n\n\t\tif (fromIndex < this.#activeTrackIndex && toIndex >= this.#activeTrackIndex) {\n\t\t\tthis.#activeTrackIndex -= 1\n\t\t\treturn\n\t\t}\n\n\t\tif (fromIndex > this.#activeTrackIndex && toIndex <= this.#activeTrackIndex) {\n\t\t\tthis.#activeTrackIndex += 1\n\t\t}\n\t}\n\n\t#removeByIndex = (index: number, trackId: number): void => {\n\t\tif (this.#itemsIdsShuffled) {\n\t\t\tthis.#itemsIdsShuffled.splice(index, 1)\n\t\t\tconst originalIndex = this.#itemsIdsOriginalOrder.indexOf(trackId)\n\t\t\tif (originalIndex !== -1) {\n\t\t\t\tthis.#itemsIdsOriginalOrder.splice(originalIndex, 1)\n\t\t\t}\n\t\t} else {\n\t\t\tthis.#itemsIdsOriginalOrder.splice(index, 1)\n\t\t}\n\n\t\tif (index < this.#activeTrackIndex) {\n\t\t\tthis.#activeTrackIndex -= 1\n\t\t} else if (index === this.#activeTrackIndex) {\n\t\t\tthis.#activeTrackIndex = -1\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/lib/stores/player/use-store.ts",
    "content": "import { createContext } from 'svelte'\nimport type { PlayerStore } from './player.svelte.ts'\n\nexport const [usePlayer, setPlayerStoreContext] = createContext<PlayerStore>()\n"
  },
  {
    "path": "src/lib/theme.ts",
    "content": "import {\n\targbFromHex,\n\tCam16,\n\tHctSolver,\n\thexFromArgb,\n\t// biome-ignore lint/style/noRestrictedImports: Main module for theme utilities\n} from '@material/material-color-utilities'\n\n/** @public */\nexport type PaletteToken =\n\t| 'primary'\n\t| 'onPrimary'\n\t| 'primaryContainer'\n\t| 'onPrimaryContainer'\n\t| 'secondary'\n\t| 'onSecondary'\n\t| 'secondaryContainer'\n\t| 'secondaryContainerVariant'\n\t| 'onSecondaryContainer'\n\t| 'tertiary'\n\t| 'onTertiary'\n\t| 'tertiaryContainer'\n\t| 'onTertiaryContainer'\n\t| 'error'\n\t| 'onError'\n\t| 'errorContainer'\n\t| 'onErrorContainer'\n\t| 'surface'\n\t| 'onSurface'\n\t| 'surfaceVariant'\n\t| 'onSurfaceVariant'\n\t| 'surfaceContainerHighest'\n\t| 'surfaceContainerHigh'\n\t| 'surfaceContainer'\n\t| 'surfaceContainerLow'\n\t| 'surfaceContainerLowest'\n\t| 'surfaceBright'\n\t| 'surfaceDim'\n\t| 'outline'\n\t| 'outlineVariant'\n\t| 'shadow'\n\t| 'scrim'\n\t| 'inverseSurface'\n\t| 'inverseOnSurface'\n\t| 'inversePrimary'\n\ntype Tone = 'a1' | 'a2' | 'a3' | 'n1' | 'n2' | 'error'\ntype PaletteTokenInput = readonly [tone: Tone, light: number, dark: number]\n\ntype PaletteTokensInputMap = Record<PaletteToken, PaletteTokenInput>\n\nconst COLOR_TOKENS_GENERATION_MAP: PaletteTokensInputMap = {\n\tprimary: ['a1', 40, 80],\n\tonPrimary: ['a1', 100, 20],\n\tprimaryContainer: ['a1', 90, 30],\n\tonPrimaryContainer: ['a1', 10, 90],\n\tsecondary: ['a2', 40, 80],\n\tonSecondary: ['a2', 100, 20],\n\tsecondaryContainer: ['a2', 90, 30],\n\tsecondaryContainerVariant: ['a2', 75, 15],\n\tonSecondaryContainer: ['a2', 10, 90],\n\ttertiary: ['a3', 40, 80],\n\tonTertiary: ['a3', 100, 20],\n\ttertiaryContainer: ['a3', 90, 30],\n\tonTertiaryContainer: ['a3', 10, 90],\n\terror: ['error', 40, 80],\n\tonError: ['error', 100, 20],\n\terrorContainer: ['error', 90, 30],\n\tonErrorContainer: ['error', 10, 90],\n\tsurface: ['n1', 98, 10],\n\tonSurface: ['n1', 10, 90],\n\tsurfaceVariant: ['n2', 90, 30],\n\tonSurfaceVariant: ['n2', 30, 80],\n\tsurfaceContainerHighest: ['n1', 90, 22],\n\tsurfaceContainerHigh: ['n1', 92, 17],\n\tsurfaceContainer: ['n1', 94, 12],\n\tsurfaceContainerLow: ['n1', 96, 10],\n\tsurfaceContainerLowest: ['n1', 100, 4],\n\tsurfaceBright: ['n1', 98, 24],\n\tsurfaceDim: ['n1', 87, 6],\n\toutline: ['n2', 50, 60],\n\toutlineVariant: ['n2', 80, 30],\n\tshadow: ['n1', 0, 0],\n\tscrim: ['n1', 0, 0],\n\tinverseSurface: ['n1', 20, 90],\n\tinverseOnSurface: ['n1', 95, 10],\n\tinversePrimary: ['a1', 80, 40],\n}\n\nconst COLOR_TOKENS_GENERATION_ENTRIES = Object.entries(COLOR_TOKENS_GENERATION_MAP) as [\n\tPaletteToken,\n\tPaletteTokenInput,\n][]\n\nconst createTonalPalette = (hue: number, chroma: number) => ({\n\ttone: (tone: number) => HctSolver.solveToInt(hue, chroma, tone),\n})\n\ninterface TonalPalette {\n\ttone: (argb: number) => number\n}\n\ntype ThemeEntry = [key: PaletteToken, hexValue: string]\n\n/** @public */\nexport const getThemePaletteRgbEntries = (argb: number, isDark: boolean): ThemeEntry[] => {\n\tconst cam16 = Cam16.fromInt(argb)\n\tconst hue = cam16.hue\n\tconst chroma = cam16.chroma\n\n\t// We do not use material-color-utilities CorePalette because of large bundle size\n\t// and because its color scheme is bit outdated with the current design guidelines\n\tconst palette: Record<Tone, TonalPalette> = {\n\t\ta1: createTonalPalette(hue, Math.max(48, chroma)),\n\t\ta2: createTonalPalette(hue, 16),\n\t\ta3: createTonalPalette(hue + 60, 24),\n\t\tn1: createTonalPalette(hue, 6),\n\t\tn2: createTonalPalette(hue, 8),\n\t\terror: createTonalPalette(25, 84),\n\t}\n\n\tconst transformedEntries = COLOR_TOKENS_GENERATION_ENTRIES.map(([key, value]): ThemeEntry => {\n\t\tconst [toneName, light, dark] = value\n\n\t\tconst tone = isDark ? dark : light\n\t\tconst argbValue = palette[toneName].tone(tone)\n\n\t\treturn [key, hexFromArgb(argbValue)]\n\t})\n\n\treturn transformedEntries\n}\n\nconst clearThemeCssVariables = (): void => {\n\tfor (const [key] of COLOR_TOKENS_GENERATION_ENTRIES) {\n\t\tdocument.documentElement.style.removeProperty(`--color-${key}`)\n\t}\n}\n\nconst setThemeCssVariables = (argb: number, isDark: boolean): void => {\n\tconst palette = getThemePaletteRgbEntries(argb, isDark)\n\n\tfor (const [key, hex] of palette) {\n\t\tdocument.documentElement.style.setProperty(`--color-${key}`, hex)\n\t}\n}\n\n/** @public */\nexport const updateThemeCssVariables = (\n\targbOrHex: number | string | null,\n\tisDark: boolean,\n): void => {\n\tconst argb =\n\t\ttypeof argbOrHex === 'number'\n\t\t\t? argbOrHex\n\t\t\t: typeof argbOrHex === 'string'\n\t\t\t\t? argbFromHex(argbOrHex)\n\t\t\t\t: null\n\n\tif (argb) {\n\t\tsetThemeCssVariables(argb, isDark)\n\t} else {\n\t\tclearThemeCssVariables()\n\t}\n}\n"
  },
  {
    "path": "src/lib/view-transitions.svelte.ts",
    "content": "import type { AfterNavigate, OnNavigate } from '@sveltejs/kit'\nimport { browser } from '$app/environment'\nimport { onNavigate } from '$app/navigation'\nimport type { RouteId } from '$app/types'\nimport { getActiveRipplesCount } from './attachments/ripple.ts'\nimport { wait } from './helpers/utils/wait.ts'\n\nexport type AppViewTransitionType = 'regular' | 'player' | 'library' | 'disabled'\n\nexport type AppViewTransitionTypeMatcherResult = {\n\tview: AppViewTransitionType\n\tbackwards?: boolean\n} | null\n\nexport type AppViewTransitionTypeMatcher = (\n\tto: RouteId,\n\tfrom: RouteId,\n) => AppViewTransitionTypeMatcherResult\n\nconst matchers: (AppViewTransitionTypeMatcher | undefined)[] = []\n\nexport const defineViewTransitionMatcher = (callback: AppViewTransitionTypeMatcher): void => {\n\tmatchers.unshift(callback)\n\t// We only care about last and current matcher,\n\t// matches from previous routes are not relevant.\n\tmatchers.length = 2\n}\n\ntype ViewTransitionReadyListener = (\n\tstate: 'before-nav' | 'after-nav',\n\tmatch: Exclude<AppViewTransitionTypeMatcherResult, null>,\n) => void\n\nconst listeners = new Set<ViewTransitionReadyListener>()\n\nconst notifyListeners: ViewTransitionReadyListener = (state, match) => {\n\tfor (const listener of listeners) {\n\t\tlistener(state, match)\n\t}\n}\n\nexport const onViewTransitionPrepare = (listener: ViewTransitionReadyListener) => {\n\t$effect.pre(() => {\n\t\tlisteners.add(listener)\n\n\t\treturn () => {\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tlisteners.delete(listener)\n\t\t\t})\n\t\t}\n\t})\n}\n\nconst viewTransitionsUnsupported = !(\n\tbrowser &&\n\t!!document.startViewTransition &&\n\tglobalThis.ViewTransitionTypeSet\n)\n\nconst resolveView = (nav: OnNavigate | AfterNavigate) => {\n\tconst to = nav.to?.route.id\n\tconst from = nav.from?.route.id\n\n\tlet customMatch: AppViewTransitionTypeMatcherResult | undefined\n\tif (to && from) {\n\t\tfor (const matcher of matchers) {\n\t\t\tconst match = matcher?.(to as RouteId, from as RouteId)\n\n\t\t\tif (match) {\n\t\t\t\tcustomMatch = match\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tconst goingBackwards = nav.delta ? nav.delta < 0 : false\n\tconst isBackwards = customMatch?.backwards ?? goingBackwards\n\tconst view = customMatch?.view ?? 'regular'\n\n\treturn { view, isBackwards }\n}\n\nexport const setupAppViewTransitions = (disabled: () => boolean): void => {\n\tonNavigate(async (nav) => {\n\t\tif (disabled() || viewTransitionsUnsupported) {\n\t\t\treturn\n\t\t}\n\n\t\tconst { promise, resolve } = Promise.withResolvers<void>()\n\n\t\tif (getActiveRipplesCount() > 0) {\n\t\t\t// Allow ripple animations to finish before transitioning\n\t\t\tawait wait(175)\n\t\t}\n\n\t\tconst { view, isBackwards } = resolveView(nav)\n\n\t\tif (view === 'disabled') {\n\t\t\treturn\n\t\t}\n\n\t\tdocument.startViewTransition({\n\t\t\tupdate: () => {\n\t\t\t\tnotifyListeners('before-nav', {\n\t\t\t\t\tview,\n\t\t\t\t\tbackwards: isBackwards,\n\t\t\t\t})\n\t\t\t\tresolve()\n\t\t\t\treturn nav.complete.then(() => {\n\t\t\t\t\tnotifyListeners('after-nav', {\n\t\t\t\t\t\tview,\n\t\t\t\t\t\tbackwards: isBackwards,\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t\t},\n\t\t\ttypes: [view, isBackwards ? 'backwards' : 'forwards'],\n\t\t})\n\n\t\treturn promise\n\t})\n}\n"
  },
  {
    "path": "src/params/libraryEntities.ts",
    "content": "const libraryEntitiesSlugs = ['tracks', 'albums', 'artists', 'playlists'] as const\ntype LibraryEntitiesSlug = (typeof libraryEntitiesSlugs)[number]\n\nconst entities = new Set(libraryEntitiesSlugs)\n\nexport const match = (param): param is LibraryEntitiesSlug =>\n\tentities.has(param as LibraryEntitiesSlug)\n"
  },
  {
    "path": "src/routes/(app)/(plain)/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from '$app/state'\n\timport Header from '$lib/components/Header.svelte'\n\n\tconst { children } = $props()\n</script>\n\n<Header title={page.data.title} />\n\n<div class=\"flex grow flex-col px-4\">\n\t{@render children()}\n</div>\n\n<div class=\"mt-4 h-(--bottom-overlay-height) shrink-0\"></div>\n"
  },
  {
    "path": "src/routes/(app)/(plain)/about/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { ripple } from '$lib/attachments/ripple'\n\timport Icon, { type IconType } from '$lib/components/icon/Icon.svelte'\n\n\tinterface Links {\n\t\ticon: IconType\n\t\ttitle: string\n\t\thref: string\n\t}\n\n\tconst links: Links[] = [\n\t\t{\n\t\t\ttitle: m.aboutSourceCode(),\n\t\t\thref: 'https://github.com/minht11/local-music-pwa',\n\t\t\ticon: 'github',\n\t\t},\n\t\t{\n\t\t\ttitle: m.aboutPrivacy(),\n\t\t\thref: 'https://github.com/minht11/local-music-pwa#privacy',\n\t\t\ticon: 'lockCheck',\n\t\t},\n\t\t{\n\t\t\ttitle: m.aboutJoinDiscord(),\n\t\t\thref: 'https://discord.gg/9z3BnHuXZb',\n\t\t\ticon: 'discord',\n\t\t},\n\t\t{\n\t\t\ttitle: m.aboutHomepage(),\n\t\t\thref: '/',\n\t\t\ticon: 'home',\n\t\t},\n\t]\n</script>\n\n<section class=\"m-auto flex w-full flex-col select-text sm:max-w-lg\">\n\t<div class=\"mb-10 flex flex-col items-center gap-4 text-center text-headline-md\">\n\t\t<img src=\"/icons/responsive.svg\" class=\"size-16\" alt=\"Logo\" />\n\t\t{m.appName()}\n\t</div>\n\n\t<ul class=\"grid w-full grid-cols-1 gap-2 sm:grid-cols-2\">\n\t\t{#each links as link}\n\t\t\t<li>\n\t\t\t\t<a\n\t\t\t\t\t{@attach ripple()}\n\t\t\t\t\thref={link.href}\n\t\t\t\t\tclass=\"relative flex w-full items-center gap-4 overflow-clip rounded-lg border border-primary/10 bg-surfaceContainerLowest p-4 transition-transform hover:-translate-y-1\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener\"\n\t\t\t\t>\n\t\t\t\t\t<Icon type={link.icon} class=\"size-5\" />\n\t\t\t\t\t{link.title}\n\t\t\t\t\t<Icon type=\"openInNew\" class=\"ml-auto size-4 text-onSurfaceVariant\" />\n\t\t\t\t</a>\n\t\t\t</li>\n\t\t{/each}\n\t</ul>\n</section>\n"
  },
  {
    "path": "src/routes/(app)/(plain)/about/+page.ts",
    "content": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): { title: string } => ({\n\ttitle: m.about(),\n})\n"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { tooltip } from '$lib/attachments/tooltip.ts'\n\timport Button from '$lib/components/Button.svelte'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport Select from '$lib/components/Select.svelte'\n\timport Separator from '$lib/components/Separator.svelte'\n\timport Slider from '$lib/components/Slider.svelte'\n\timport Spinner from '$lib/components/Spinner.svelte'\n\timport Switch from '$lib/components/Switch.svelte'\n\timport { isDatabaseOperationPending } from '$lib/db/lock-database.ts'\n\timport { initPageQueries } from '$lib/db/query/page-query.svelte.ts'\n\timport { supportsChangingAudioVolume } from '$lib/helpers/audio.ts'\n\timport { Debounced } from '$lib/helpers/debounced.svelte.ts'\n\timport { isFileSystemAccessSupported } from '$lib/helpers/file-system.ts'\n\timport { debounce } from '$lib/helpers/utils/debounce.ts'\n\timport type { AppMotionOption, AppThemeOption } from '$lib/stores/main/store.svelte.ts'\n\timport {\n\t\tPLAYER_PLAYBACK_RATE_MAX,\n\t\tPLAYER_PLAYBACK_RATE_MIN,\n\t} from '$lib/stores/player/player.svelte.ts'\n\timport { getLocale, type Locale, setLocale } from '$paraglide/runtime.js'\n\timport DirectoriesList from './components/DirectoriesList.svelte'\n\timport InstallAppBanner from './components/InstallAppBanner.svelte'\n\timport MissingFsApiBanner from './components/MissingFsApiBanner.svelte'\n\n\tconst { data } = $props()\n\n\tinitPageQueries(() => data)\n\n\tconst mainStore = useMainStore()\n\tconst player = usePlayer()\n\tconst dialogs = useDialogsStore()\n\n\tconst directories = $derived(data.directoriesQuery.value)\n\n\tconst themeOptions: { name: string; value: AppThemeOption }[] = [\n\t\t{\n\t\t\tname: m.settingsThemeAuto(),\n\t\t\tvalue: 'auto',\n\t\t},\n\t\t{\n\t\t\tname: m.settingsThemeDark(),\n\t\t\tvalue: 'dark',\n\t\t},\n\t\t{\n\t\t\tname: m.settingsThemeLight(),\n\t\t\tvalue: 'light',\n\t\t},\n\t]\n\n\tconst motionOptions: { name: string; value: AppMotionOption }[] = [\n\t\t{\n\t\t\tname: m.settingsMotionAuto(),\n\t\t\tvalue: 'auto',\n\t\t},\n\t\t{\n\t\t\tname: m.settingsMotionReduced(),\n\t\t\tvalue: 'reduced',\n\t\t},\n\t\t{\n\t\t\tname: m.settingsMotionNormal(),\n\t\t\tvalue: 'normal',\n\t\t},\n\t]\n\n\tconst languageOptions: { name: string; value: Locale }[] = [\n\t\t{ name: 'English (EN)', value: 'en' },\n\t\t{ name: 'Lietuvių (LT)', value: 'lt' },\n\t\t{ name: 'Deutsch (DE)', value: 'de' },\n\t\t{ name: 'Français (FR)', value: 'fr' },\n\t\t{ name: '简体中文', value: 'zh-CN' },\n\t\t{ name: '繁體中文', value: 'zh-TW' },\n\t]\n\n\tconst updateMainColor = debounce((value: string | null) => {\n\t\tmainStore.customThemePaletteHex = value\n\t}, 400)\n\n\t// We debounce state updates, because some DB operations can be very fast.\n\t// This prevents UI from flickering\n\tconst isDatabasePendingGetter = new Debounced(() => isDatabaseOperationPending(), 200)\n\tconst isDatabasePending = $derived(isDatabasePendingGetter.current)\n</script>\n\n{#snippet heading(text: string)}\n\t<div class=\"px-4 pt-4 text-title-sm text-onSurfaceVariant\">{text}</div>\n{/snippet}\n\n<section class=\"card settings-max-width mx-auto w-full overflow-clip\">\n\t<div class=\"flex flex-col p-4\">\n\t\t<div class=\"flex items-center gap-2 text-title-sm\">\n\t\t\t{m.settingsDirectories()}\n\t\t</div>\n\t\t<div class=\"mt-1 mb-4 text-body-sm text-onSurfaceVariant\">\n\t\t\t{m.settingsAllDataLocal()}\n\t\t</div>\n\n\t\t{#if !isFileSystemAccessSupported}\n\t\t\t<MissingFsApiBanner />\n\t\t{/if}\n\t\t<DirectoriesList disabled={isDatabasePending} {directories} />\n\n\t\t{#if isDatabasePending}\n\t\t\t<div\n\t\t\t\tclass=\"mt-4 flex w-full items-center justify-center gap-4 rounded-md bg-tertiaryContainer/20 py-4\"\n\t\t\t>\n\t\t\t\t{m.settingsDbOperationInProgress()}\n\t\t\t\t<Spinner class=\"size-8\" />\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n</section>\n\n<InstallAppBanner class=\"settings-max-width mt-6\" />\n\n<section class=\"card settings-max-width mx-auto mt-6 w-full text-body-lg\">\n\t{@render heading(m.settingsAppearance())}\n\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div>{m.settingsApplicationTheme()}</div>\n\n\t\t<Select\n\t\t\tbind:selected={mainStore.theme}\n\t\t\titems={themeOptions}\n\t\t\tkey=\"value\"\n\t\t\tlabelKey=\"name\"\n\t\t\tclass=\"w-40\"\n\t\t/>\n\t</div>\n\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div>{m.settingPickColorFromArtwork()}</div>\n\n\t\t<Switch bind:checked={mainStore.pickColorFromArtwork} />\n\t</div>\n\n\t<div class=\"flex flex-col items-center gap-x-2 gap-y-4 p-4 sm:flex-row\">\n\t\t<div class=\"mr-auto flex items-center gap-2\">\n\t\t\t{m.settingsPrimaryColor()}\n\n\t\t\t{#if mainStore.customThemePaletteHex}\n\t\t\t\t<div\n\t\t\t\t\tclass=\"pointer-events-none size-6 shrink-0 items-center justify-center rounded-md ring ring-outline/40\"\n\t\t\t\t\tstyle:background={mainStore.customThemePaletteHex}\n\t\t\t\t></div>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<div class=\"flex items-center gap-2 max-sm:w-full\">\n\t\t\t{#if mainStore.customThemePaletteHex}\n\t\t\t\t<Button\n\t\t\t\t\tkind=\"outlined\"\n\t\t\t\t\tclass=\"max-sm:w-full\"\n\t\t\t\t\tdisabled={!mainStore.customThemePaletteHex}\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tmainStore.customThemePaletteHex = null\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{m.settingsColorReset()}\n\t\t\t\t</Button>\n\t\t\t{/if}\n\n\t\t\t<Button\n\t\t\t\tkind=\"toned\"\n\t\t\t\tclass=\"max-sm:w-full\"\n\t\t\t\tonclick={() => {\n\t\t\t\t\tconst colorPicker = document.getElementById('color-picker') as HTMLInputElement\n\t\t\t\t\tcolorPicker.click()\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Icon type=\"eyedropper\" class=\"size-5\" />\n\n\t\t\t\t{m.settingsColorPick()}\n\n\t\t\t\t<input\n\t\t\t\t\tid=\"color-picker\"\n\t\t\t\t\ttype=\"color\"\n\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\tbind:value={\n\t\t\t\t\t\t() => mainStore.customThemePaletteHex ?? '#000000', (value) => updateMainColor(value)\n\t\t\t\t\t}\n\t\t\t\t\tclass=\"pointer-events-none absolute inset-0 size-full appearance-none opacity-0\"\n\t\t\t\t/>\n\t\t\t</Button>\n\t\t</div>\n\t</div>\n\n\t<Separator />\n\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div>{m.settingsMotion()}</div>\n\n\t\t<Select\n\t\t\tbind:selected={mainStore.motion}\n\t\t\titems={motionOptions}\n\t\t\tkey=\"value\"\n\t\t\tlabelKey=\"name\"\n\t\t\tclass=\"w-40\"\n\t\t/>\n\t</div>\n</section>\n\n<section class=\"card settings-max-width mx-auto mt-6 w-full text-body-lg\">\n\t{@render heading(m.player())}\n\n\t{#if supportsChangingAudioVolume()}\n\t\t<div class=\"flex items-center justify-between p-4\">\n\t\t\t<div>{m.settingsDisplayVolumeSlider()}</div>\n\n\t\t\t<Switch bind:checked={mainStore.volumeSliderEnabled} />\n\t\t</div>\n\n\t\t<Separator />\n\t{/if}\n\n\t<div class=\"flex flex-col justify-between gap-y-4 p-4 sm:flex-row sm:items-center\">\n\t\t<div class=\"flex items-center gap-2\">\n\t\t\t<div>{m.equalizerTitle()}</div>\n\n\t\t\t{#if player.equalizer.enabled}\n\t\t\t\t<div\n\t\t\t\t\tclass=\"rounded-full bg-primaryContainer px-2 py-0.5 text-label-sm text-onPrimaryContainer\"\n\t\t\t\t>\n\t\t\t\t\t{m.equalizerStatusEnabled()}\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<Button\n\t\t\tkind=\"toned\"\n\t\t\tonclick={() => {\n\t\t\t\tdialogs.openDialog('equalizer')\n\t\t\t}}\n\t\t>\n\t\t\t{m.equalizerOpenEqualizer()}\n\t\t</Button>\n\t</div>\n\n\t<Separator />\n\n\t<div class=\"flex flex-col justify-between gap-3 p-4 sm:flex-row sm:items-center\">\n\t\t<div>{m.settingsPlaybackSpeed()}</div>\n\n\t\t<div class=\"flex w-full items-center gap-3 sm:w-56\">\n\t\t\t<div class=\"w-12 text-center text-label-lg tabular-nums sm:text-right\">\n\t\t\t\t{player.playbackRate}x\n\t\t\t</div>\n\n\t\t\t<Slider\n\t\t\t\tmin={PLAYER_PLAYBACK_RATE_MIN}\n\t\t\t\tmax={PLAYER_PLAYBACK_RATE_MAX}\n\t\t\t\tstep={0.05}\n\t\t\t\tbind:value={player.playbackRate}\n\t\t\t/>\n\t\t</div>\n\t</div>\n\n\t<div class=\"flex justify-end px-4 pb-4\">\n\t\t<Button\n\t\t\tkind=\"outlined\"\n\t\t\tdisabled={player.playbackRate === 1}\n\t\t\tonclick={() => {\n\t\t\t\tplayer.playbackRate = 1\n\t\t\t}}\n\t\t>\n\t\t\t{m.settingsPlaybackSpeedReset()}\n\t\t</Button>\n\t</div>\n\n\t<Separator />\n\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div class=\"flex items-center gap-2\">\n\t\t\t<div>{m.settingsPreservePitch()}</div>\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"interactable flex size-6 items-center justify-center rounded-full text-onSurfaceVariant\"\n\t\t\t\t{@attach tooltip(m.settingsPreservePitchInfo())}\n\t\t\t>\n\t\t\t\t<Icon type=\"information\" class=\"size-4\" />\n\t\t\t</button>\n\t\t</div>\n\n\t\t<Switch bind:checked={player.preservePitch} />\n\t</div>\n</section>\n\n<section class=\"card settings-max-width mx-auto mt-6 w-full text-body-lg\">\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div>{m.settingsLanguage()}</div>\n\n\t\t<Select\n\t\t\tbind:selected={() => getLocale(), setLocale}\n\t\t\titems={languageOptions}\n\t\t\tkey=\"value\"\n\t\t\tlabelKey=\"name\"\n\t\t\tclass=\"w-40\"\n\t\t/>\n\t</div>\n</section>\n\n<section class=\"card settings-max-width mx-auto mt-6 w-full text-body-lg\">\n\t<div class=\"flex items-center justify-between p-4\">\n\t\t<div>{m.about()}</div>\n\n\t\t<IconButton as=\"a\" href=\"/about\" tooltip={m.about()} icon=\"chevronRight\" />\n\t</div>\n</section>\n\n<style lang=\"postcss\">\n\t@reference '../../../../app.css';\n\n\t:global(.settings-max-width) {\n\t\tmax-width: --spacing(225);\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/+page.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts'\nimport { type Directory, LEGACY_NO_NATIVE_DIRECTORY } from '$lib/library/types.ts'\n\nexport type DirectoryWithCount = { count: number } & (\n\t| {\n\t\t\tid: typeof LEGACY_NO_NATIVE_DIRECTORY\n\t\t\tlegacy: true\n\t  }\n\t| (Directory & { legacy?: false })\n)\n\nconst createDirectoriesPageQuery = () =>\n\tcreatePageQuery({\n\t\tkey: [],\n\t\tfetcher: async (): Promise<DirectoryWithCount[]> => {\n\t\t\tconst db = await getDatabase()\n\t\t\tconst directories = await db.getAll('directories')\n\t\t\tconst tx = db.transaction('tracks')\n\t\t\tconst directoriesIndex = tx.objectStore('tracks').index('directory')\n\n\t\t\tconst directoriesWithCount = await Promise.all([\n\t\t\t\t...directories.map(async (directory) => ({\n\t\t\t\t\t...directory,\n\t\t\t\t\tcount: await directoriesIndex.count(directory.id),\n\t\t\t\t})),\n\t\t\t\tdirectoriesIndex.count(LEGACY_NO_NATIVE_DIRECTORY).then(\n\t\t\t\t\t(count): DirectoryWithCount => ({\n\t\t\t\t\t\tid: LEGACY_NO_NATIVE_DIRECTORY,\n\t\t\t\t\t\tlegacy: true,\n\t\t\t\t\t\tcount,\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t])\n\n\t\t\tconst legacyDir = directoriesWithCount.at(-1)\n\t\t\tif (legacyDir && legacyDir.count === 0) {\n\t\t\t\t// Remove the legacy directory if it has no tracks\n\t\t\t\tdirectoriesWithCount.pop()\n\t\t\t}\n\n\t\t\treturn directoriesWithCount\n\t\t},\n\t\tonDatabaseChange: (changes, { refetch }) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === 'tracks' || change.storeName === 'directories') {\n\t\t\t\t\trefetch()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t})\n\ninterface LoadResult {\n\tdirectoriesQuery: PageQueryResult<DirectoryWithCount[]>\n\ttitle: string\n}\n\nexport const load = async (): Promise<LoadResult> => {\n\tconst directories = await createDirectoriesPageQuery()\n\n\treturn {\n\t\tdirectoriesQuery: directories,\n\t\ttitle: m.settings(),\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/DirectoriesList.svelte",
    "content": "<script lang=\"ts\">\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport { tooltip } from '$lib/attachments/tooltip.ts'\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport WrapTranslation from '$lib/components/WrapTranslation.svelte'\n\timport {\n\t\tgetFilesFromLegacyDirectory,\n\t\tisFileSystemAccessSupported,\n\t} from '$lib/helpers/file-system.ts'\n\timport { isAndroid } from '$lib/helpers/utils/ua.ts'\n\timport {\n\t\tcheckNewDirectoryStatus,\n\t\timportLegacyFiles,\n\t\timportNewDirectory,\n\t\tremoveDirectory,\n\t\treplaceDirectories,\n\t\trescanDirectory,\n\t} from '$lib/library/scan-actions/directories.ts'\n\timport type { Directory } from '$lib/library/types.ts'\n\timport type { DirectoryWithCount } from '../+page.ts'\n\n\tinterface Props {\n\t\tdisabled: boolean\n\t\tdirectories: DirectoryWithCount[]\n\t}\n\n\tconst { disabled, directories }: Props = $props()\n\n\tinterface ReparentDirectory {\n\t\tchildDirs: Directory[]\n\t\tnewDirHandle: FileSystemDirectoryHandle\n\t}\n\n\tconst isAndroidPlatform = isAndroid()\n\tlet reparentDirectory = $state<ReparentDirectory | null>(null)\n\n\tconst addNewDirectoryHandler = async () => {\n\t\tconst directory = await showDirectoryPicker({\n\t\t\tmode: 'read',\n\t\t})\n\n\t\tlet childDirectories: Directory[] = []\n\t\tfor (const existingDir of directories) {\n\t\t\tif (existingDir.legacy) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tconst result = await checkNewDirectoryStatus(existingDir, directory)\n\t\t\tif (result?.status === 'existing') {\n\t\t\t\tsnackbar({\n\t\t\t\t\tid: 'directory-already-included',\n\t\t\t\t\tmessage: `Directory '${directory.name}' is already included`,\n\t\t\t\t})\n\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (result?.status === 'child') {\n\t\t\t\tsnackbar({\n\t\t\t\t\tid: 'directory-added',\n\t\t\t\t\tmessage: m.directoryIsIncludedInParent({\n\t\t\t\t\t\texistingDir: existingDir.handle.name,\n\t\t\t\t\t\tnewDir: directory.name,\n\t\t\t\t\t}),\n\t\t\t\t})\n\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (result) {\n\t\t\t\tchildDirectories.push(result.existingDir)\n\t\t\t}\n\t\t}\n\n\t\tif (childDirectories.length > 0) {\n\t\t\treparentDirectory = {\n\t\t\t\tchildDirs: childDirectories,\n\t\t\t\tnewDirHandle: directory,\n\t\t\t}\n\t\t} else {\n\t\t\twindow.goatcounter?.count({ path: 'action-import-directory', event: true })\n\t\t\tvoid importNewDirectory(directory)\n\t\t}\n\t}\n\n\tconst importLegacyFilesHandler = async () => {\n\t\tconst files = await getFilesFromLegacyDirectory().catch((e): File[] => {\n\t\t\tsnackbar.unexpectedError(e)\n\n\t\t\treturn []\n\t\t})\n\n\t\tif (files.length === 0) {\n\t\t\treturn\n\t\t}\n\n\t\twindow.goatcounter?.count({ path: 'action-import-files', event: true })\n\t\tawait importLegacyFiles(files)\n\t}\n</script>\n\n{#snippet addButton(title: string, onclick: () => void)}\n\t<button\n\t\t{@attach ripple()}\n\t\ttype=\"button\"\n\t\tclass={[\n\t\t\tdisabled ? 'bg-surfaceContainer/10 text-onSurface/54' : 'interactable',\n\t\t\t'flex h-16 items-center gap-2 rounded-sm px-4 ring-1 ring-outlineVariant ring-inset',\n\t\t]}\n\t\t{disabled}\n\t\t{onclick}\n\t>\n\t\t<Icon type=\"plus\" />\n\t\t{title}\n\t</button>\n{/snippet}\n\n<ul class=\"grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2\">\n\t{#each directories as dir}\n\t\t<li\n\t\t\tclass={[\n\t\t\t\t'flex h-16 items-center gap-2 rounded-sm pr-1 pl-4 text-onTertiaryContainer',\n\t\t\t\tdir.legacy ? 'bg-tertiaryContainer/40' : 'bg-tertiaryContainer/56',\n\t\t\t]}\n\t\t>\n\t\t\t{#if dir.legacy}\n\t\t\t\t<div {@attach tooltip(m.settingsTracksInAppStorageTooltip())}>\n\t\t\t\t\t<Icon type=\"information\" class=\"size-4 text-onTertiaryContainer/54\" />\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<div class=\"flex flex-col overflow-hidden\">\n\t\t\t\t<div class=\"truncate\">\n\t\t\t\t\t{dir.legacy ? m.settingsTracksInsideAppMemory() : dir.handle.name}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"text-body-sm\">\n\t\t\t\t\t{m.settingsDirectoriesTracksCount({ count: dir.count })}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"ml-auto flex items-center gap-1\">\n\t\t\t\t<!-- Chromium on Android broke persisted handles https://issues.chromium.org/issues/499064852 -->\n\t\t\t\t{#if !(dir.legacy || isAndroidPlatform)}\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\t{disabled}\n\t\t\t\t\t\ticon=\"cached\"\n\t\t\t\t\t\ttooltip={m.settingsDirRescan()}\n\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\twindow.goatcounter?.count({ path: 'action-rescan-directory', event: true })\n\t\t\t\t\t\t\tvoid rescanDirectory(dir.id, dir.handle)\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\t\t\t\t<IconButton\n\t\t\t\t\t{disabled}\n\t\t\t\t\ticon=\"trashOutline\"\n\t\t\t\t\ttooltip={m.settingsDirRemove()}\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tvoid removeDirectory(dir.id)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</li>\n\t{/each}\n\t<li class=\"contents\">\n\t\t{#if isFileSystemAccessSupported}\n\t\t\t{@render addButton(m.settingsAddDirectory(), addNewDirectoryHandler)}\n\t\t{:else}\n\t\t\t{@render addButton(m.settingsImportTracks(), importLegacyFilesHandler)}\n\t\t{/if}\n\t</li>\n</ul>\n\n{#snippet directoryName(name: string | undefined)}\n\t<span class=\"inline-flex h-[--spacing(4.125)] w-fit items-center gap-1 text-tertiary\">\n\t\t<Icon type=\"folder\" class=\"mt-0.5 size-3\" />\n\n\t\t<span class=\"inline h-full w-fit max-w-25 truncate\">{name}</span>\n\t</span>\n{/snippet}\n\n<CommonDialog\n\topen={{\n\t\tget: () => reparentDirectory,\n\t\tclose: () => {\n\t\t\treparentDirectory = null\n\t\t},\n\t}}\n\tclass=\"[--dialog-width:--spacing(85)]\"\n\ticon=\"folderHidden\"\n\ttitle={m.replaceDirectoryQ()}\n\tbuttons={(data) => [\n\t\t{\n\t\t\ttitle: m.cancel(),\n\t\t},\n\t\t{\n\t\t\ttitle: m.replace(),\n\t\t\taction: () => {\n\t\t\t\tconst ids = data.childDirs.map((dir) => dir.id)\n\t\t\t\tvoid replaceDirectories(data.newDirHandle, ids)\n\t\t\t},\n\t\t},\n\t]}\n>\n\t{#snippet children({ data })}\n\t\t<WrapTranslation messageFn={m.replaceDirectoryExplanation}>\n\t\t\t{#snippet existingDirs()}\n\t\t\t\t{#each data.childDirs as dir}\n\t\t\t\t\t{@render directoryName(dir.handle.name)}\n\t\t\t\t{/each}\n\t\t\t{/snippet}\n\t\t\t{#snippet newDir()}\n\t\t\t\t{@render directoryName(data.newDirHandle.name)}\n\t\t\t{/snippet}\n\t\t</WrapTranslation>\n\t{/snippet}\n</CommonDialog>\n"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/InstallAppBanner.svelte",
    "content": "<script lang=\"ts\">\n\timport Button from '$lib/components/Button.svelte'\n\timport { isMobile } from '$lib/helpers/utils/ua.ts'\n\n\tinterface Props {\n\t\tclass: ClassValue\n\t}\n\n\tconst { class: className }: Props = $props()\n\n\tconst main = useMainStore()\n\tconst isHandHeldDevice = isMobile()\n\n\tconst install = async (e: BeforeInstallPromptEvent) => {\n\t\tawait e.prompt()\n\n\t\twindow.goatcounter?.count({\n\t\t\tpath: 'click-settings-install-app',\n\t\t\ttitle: 'Clicked settings install app',\n\t\t\tevent: true,\n\t\t})\n\t}\n\n\tconst installEvent = $derived(main.appInstallPromptEvent)\n</script>\n\n{#if installEvent}\n\t<section\n\t\tclass={[\n\t\t\t'card mx-auto w-full items-center justify-between gap-2 bg-primary/12 p-4 text-body-lg sm:flex-row',\n\t\t\tclassName,\n\t\t]}\n\t>\n\t\t<div>\n\t\t\t{m.settingsInstallAppExplanation({\n\t\t\t\tdevice: isHandHeldDevice ? m.settingsInstallAppHomeScreen() : m.settingsInstallAppDesktop(),\n\t\t\t})}\n\t\t</div>\n\n\t\t<Button class=\"w-full sm:w-35\" onclick={() => install(installEvent)}>\n\t\t\t{m.settingsInstallAppHomeAction()}\n\t\t</Button>\n\t</section>\n{/if}\n"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/MissingFsApiBanner.svelte",
    "content": "<script lang=\"ts\">\n\timport Icon from '$lib/components/icon/Icon.svelte'\n</script>\n\n<div\n\tclass=\"mb-4 flex flex-col gap-4 rounded-lg border border-outlineVariant p-4 text-onSurfaceVariant select-text\"\n>\n\t<Icon type=\"alertCircle\" class=\"shrink-0\" />\n\n\t<span>\n\t\t{m.settingsMissingFs1()}\n\t\t<a\n\t\t\tclass=\"link inline\"\n\t\t\ttarget=\"_blank\"\n\t\t\trel=\"noopener noreferrer\"\n\t\t\thref=\"https://caniuse.com/?search=showDirectoryPicker\"\n\t\t>\n\t\t\t{m.settingsMissingFs2()}\n\t\t</a>{m.settingsMissingFs3()}\n\t\t<strong>{m.settingsMissingFs4()}</strong>\n\t\t{m.settingsMissingFs5()}\n\t</span>\n</div>\n"
  },
  {
    "path": "src/routes/(app)/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { browser } from '$app/environment'\n\timport { navigating, page } from '$app/state'\n\timport Button from '$lib/components/Button.svelte'\n\timport {\n\t\tAPP_DIALOGS_COMPONENTS_MAP,\n\t\tAPP_DIALOGS_KEYS,\n\t} from '$lib/components/global-dialogs/dialogs.ts'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport MenuRenderer, { setupGlobalMenu } from '$lib/components/menu/MenuRenderer.svelte'\n\timport PlayerOverlay from '$lib/components/PlayerOverlay.svelte'\n\timport SnackbarRenderer from '$lib/components/snackbar/SnackbarRenderer.svelte'\n\timport { isElementTextInput } from '$lib/helpers/input.ts'\n\timport { setupOverlaySnippets } from '$lib/layout-bottom-bar.svelte'\n\timport { DialogsStore } from '$lib/stores/dialogs/store.svelte.ts'\n\timport { setDialogsStoreContext } from '$lib/stores/dialogs/use-store.ts'\n\timport { PlayerStore } from '$lib/stores/player/player.svelte.ts'\n\timport { setPlayerStoreContext } from '$lib/stores/player/use-store.ts'\n\timport { onViewTransitionPrepare } from '$lib/view-transitions.svelte.ts'\n\timport { setupAppInstallPromptListeners } from './layout/app-install-prompt.ts'\n\timport {\n\t\ttype DirectoriesPermissionPromptSnackbarArg,\n\t\tsetupDirectoriesPermissionPrompt,\n\t} from './layout/setup-directories-permission-prompt.svelte.ts'\n\timport { setupTheme } from './layout/setup-theme.svelte.ts'\n\n\t// These context are in different files from their implementation\n\t// to allow better trees shaking and inlining\n\tconst player = setPlayerStoreContext(new PlayerStore())\n\tconst dialogs = setDialogsStoreContext(new DialogsStore())\n\n\tsetupTheme()\n\tsetupGlobalMenu()\n\tsetupAppInstallPromptListeners()\n\tconst overlaySnippets = setupOverlaySnippets()\n\n\tconst { children } = $props()\n\n\tlet overlayContentHeight = $state(0)\n\tlet bottomBarHeight = $state(0)\n\n\t$effect(() => {\n\t\tdocument.documentElement.style.setProperty(\n\t\t\t'--bottom-overlay-height',\n\t\t\t`${overlayContentHeight + bottomBarHeight}px`,\n\t\t)\n\t})\n\n\tonViewTransitionPrepare((_state, match) => {\n\t\tif (match.view === 'player') {\n\t\t\tconst target = document.querySelector('#mini-player')\n\t\t\tconst rect = target?.getBoundingClientRect()\n\n\t\t\tif (!rect) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst setProperty = (name: keyof DOMRect) => {\n\t\t\t\tdocument.documentElement.style.setProperty(`--mp-${name}`, `${rect[name]}px`)\n\t\t\t}\n\n\t\t\tsetProperty('left')\n\t\t\tsetProperty('bottom')\n\t\t\tsetProperty('width')\n\t\t\tsetProperty('height')\n\t\t}\n\t})\n\n\tif (browser) {\n\t\tvoid setupDirectoriesPermissionPrompt(directoriesPermissionSnackbar)\n\t}\n</script>\n\n{#snippet directoriesPermissionSnackbar({ dirs, dismiss }: DirectoriesPermissionPromptSnackbarArg)}\n\t<div class=\"flex w-full flex-col gap-1 pt-2 pb-1\">\n\t\t<div>\n\t\t\t<div>{m.libraryDirPromptBrowserPermission()}</div>\n\t\t\t<div class=\"text-body-sm opacity-54\">\n\t\t\t\t{m.libraryDirPromptExplanation()}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Showing only subset at the time so snackbar does not take up the whole screen -->\n\t\t{#each dirs().slice(0, 3) as dir}\n\t\t\t<div class=\"flex items-center justify-between gap-2\">\n\t\t\t\t<Icon type=\"folder\" class=\"size-4 text-tertiaryContainer\" />\n\n\t\t\t\t<div class=\"truncate\">\n\t\t\t\t\t{dir.name}\n\t\t\t\t</div>\n\n\t\t\t\t<Button kind=\"flat\" class=\"ml-auto w-24 shrink-0 text-inversePrimary!\" onclick={dir.action}>\n\t\t\t\t\t{m.libraryDirPromptGrant()}\n\t\t\t\t</Button>\n\t\t\t</div>\n\t\t{/each}\n\n\t\t<Button kind=\"flat\" class=\"text-inversePrimary!\" onclick={dismiss}>\n\t\t\t{m.dismiss()}\n\t\t</Button>\n\t</div>\n{/snippet}\n\n<svelte:window\n\tonkeydown={(e) => {\n\t\tif (e.key === ' ' && !isElementTextInput(e.target)) {\n\t\t\te.preventDefault()\n\n\t\t\tplayer.togglePlay()\n\t\t}\n\t}}\n/>\n\n{#if navigating?.to}\n\t<div class=\"page-loading-indicator fixed inset-x-0 top-0 z-20 h-1 bg-tertiary/40\">\n\t\t<div\n\t\t\tclass=\"page-loading-indicator-bar h-1 w-full origin-top-left overflow-hidden bg-onTertiaryContainer\"\n\t\t></div>\n\t</div>\n{/if}\n\n{@render children()}\n\n<div\n\tclass=\"page-overlay-container pointer-events-none fixed inset-x-0 bottom-0 grid gap-y-2 overflow-hidden\"\n>\n\t<SnackbarRenderer />\n\n\t<div bind:clientHeight={overlayContentHeight} class=\"col-[2/5] grid grid-cols-subgrid gap-y-2\">\n\t\t{#each overlaySnippets.abovePlayer as snippet}\n\t\t\t{@render snippet()}\n\t\t{/each}\n\n\t\t{#if !page.data.noPlayerOverlay}\n\t\t\t<PlayerOverlay class={['col-[1/4]', bottomBarHeight < 0 && 'mb-2']} />\n\t\t{/if}\n\t</div>\n\n\t<div bind:clientHeight={bottomBarHeight} class=\"col-[1/6]\">\n\t\t{@render overlaySnippets.bottomBar?.()}\n\t</div>\n</div>\n\n<div class=\"pointer-events-none fixed inset-0 z-10\">\n\t<MenuRenderer />\n</div>\n\n{#each APP_DIALOGS_KEYS as dialogKey}\n\t{@const DialogComponent = APP_DIALOGS_COMPONENTS_MAP[dialogKey]}\n\n\t<DialogComponent open={dialogs.getAccessor(dialogKey)} />\n{/each}\n\n<style lang=\"postcss\">\n\t@reference '../../app.css';\n\n\t@keyframes fade-in {\n\t\tfrom {\n\t\t\topacity: 0;\n\t\t}\n\t}\n\n\t.page-loading-indicator {\n\t\tanimation: fade-in 0.2s 0.2s linear backwards;\n\t}\n\n\t.page-loading-indicator-bar {\n\t\tanimation: page-loading-indicator 6s cubic-bezier(0.05, 0.7, 0.1, 1) forwards;\n\t}\n\n\t.page-overlay-container {\n\t\t--p-overlay-side: --spacing(4);\n\t\tgrid-template-columns: var(--p-overlay-side) 1fr minmax(0, --spacing(125)) 1fr var(\n\t\t\t\t--p-overlay-side\n\t\t\t);\n\t}\n\n\t@keyframes page-loading-indicator {\n\t\t0% {\n\t\t\ttransform: scaleX(0);\n\t\t}\n\t\t100% {\n\t\t\ttransform: scaleX(0.8);\n\t\t}\n\t}\n\n\t@keyframes -global-view-regular-fade-out {\n\t\tto {\n\t\t\topacity: 0;\n\t\t}\n\t}\n\n\t@keyframes -global-view-regular-fade-in {\n\t\tfrom {\n\t\t\topacity: 0;\n\t\t}\n\t}\n\n\t@keyframes -global-view-regular-out {\n\t\tto {\n\t\t\tscale: var(--view-regular-out);\n\t\t}\n\t}\n\n\t@keyframes -global-view-regular-in {\n\t\tfrom {\n\t\t\tscale: var(--view-regular-in);\n\t\t}\n\t}\n\n\t@keyframes -global-view-bottom-bar-out {\n\t\tto {\n\t\t\ttranslate: 0 100%;\n\t\t}\n\t}\n\n\t@keyframes -global-view-bottom-bar-in {\n\t\tfrom {\n\t\t\ttranslate: 0 100%;\n\t\t}\n\t}\n\n\t:global(html:active-view-transition-type(regular)) {\n\t\t--view-regular-out: 1.1;\n\t\t--view-regular-in: 0.9;\n\n\t\t&:active-view-transition-type(backwards) {\n\t\t\t--view-regular-out: 0.9;\n\t\t\t--view-regular-in: 1.1;\n\t\t}\n\n\t\t&::view-transition-old(root) {\n\t\t\tanimation:\n\t\t\t\tview-regular-fade-out 90ms var(--ease-outgoing40) forwards,\n\t\t\t\tview-regular-out 300ms var(--ease-incoming80outgoing40);\n\t\t}\n\n\t\t&::view-transition-new(root) {\n\t\t\tanimation:\n\t\t\t\tview-regular-fade-in 210ms 90ms var(--ease-incoming80) backwards,\n\t\t\t\tview-regular-in 300ms var(--ease-incoming80outgoing40);\n\t\t}\n\n\t\t&::view-transition-old(pl-card) {\n\t\t\tdisplay: none;\n\t\t}\n\n\t\t&::view-transition-new(pl-card) {\n\t\t\tanimation: none;\n\t\t}\n\n\t\t&::view-transition-new(bottom-bar):only-child {\n\t\t\tanimation: view-bottom-bar-in 300ms var(--ease-standard) forwards;\n\t\t}\n\n\t\t&::view-transition-old(bottom-bar):only-child {\n\t\t\tanimation: view-bottom-bar-out 300ms var(--ease-standard) forwards;\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(app)/layout/app-install-prompt.ts",
    "content": "export const setupAppInstallPromptListeners = () => {\n\tconst main = useMainStore()\n\n\twindow.addEventListener('appinstalled', () => {\n\t\tmain.appInstallPromptEvent = null\n\t})\n\n\twindow.addEventListener('beforeinstallprompt', (e) => {\n\t\te.preventDefault()\n\t\tmain.appInstallPromptEvent = e\n\t})\n}\n"
  },
  {
    "path": "src/routes/(app)/layout/setup-directories-permission-prompt.svelte.ts",
    "content": "import { getDatabase } from '$lib/db/database'\n\nexport interface DirectoryNeedingPermission {\n\tname: string\n\taction: () => Promise<void>\n}\n\nexport interface DirectoriesPermissionPromptSnackbarArg {\n\tdirs: () => DirectoryNeedingPermission[]\n\tdismiss: () => void\n}\n\nconst dbGetDirectoriesNeedingPermission = async () => {\n\tconst db = await getDatabase()\n\tconst directories = await db.getAll('directories')\n\n\tconst dirsWithPermissions = await Promise.all(\n\t\tdirectories.map(async (dir) => ({\n\t\t\t...dir,\n\t\t\tmode: await dir.handle.queryPermission({ mode: 'read' }),\n\t\t})),\n\t)\n\n\treturn dirsWithPermissions.filter((d) => d.mode === 'prompt')\n}\n\nexport const setupDirectoriesPermissionPrompt = async (\n\tsnippet: Snippet<[DirectoriesPermissionPromptSnackbarArg]>,\n): Promise<void> => {\n\tconst dirsNeedingPermissionItems = await dbGetDirectoriesNeedingPermission()\n\tif (dirsNeedingPermissionItems.length === 0) {\n\t\treturn\n\t}\n\n\tconst snackbarId = 'dirs-needing-permission'\n\n\tconst dismiss = () => snackbar.dismiss(snackbarId)\n\n\tlet dirsNeedingPermission = $state(dirsNeedingPermissionItems)\n\n\tconst dirsItems = $derived(\n\t\tdirsNeedingPermission.map(\n\t\t\t(dir): DirectoryNeedingPermission => ({\n\t\t\t\tname: dir.handle.name,\n\t\t\t\taction: async () => {\n\t\t\t\t\tconst newMode = await dir.handle.requestPermission({ mode: 'read' })\n\t\t\t\t\tif (newMode === 'granted') {\n\t\t\t\t\t\tdirsNeedingPermission = dirsNeedingPermission.filter((d) => d.id !== dir.id)\n\t\t\t\t\t}\n\n\t\t\t\t\tif (dirsNeedingPermission.length === 0) {\n\t\t\t\t\t\tdismiss()\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t}),\n\t\t),\n\t)\n\n\tconst arg: DirectoriesPermissionPromptSnackbarArg = {\n\t\tdirs: () => dirsItems,\n\t\tdismiss,\n\t}\n\n\tsnackbar({\n\t\tid: snackbarId,\n\t\tmessage: '',\n\t\tduration: false,\n\t\tlayout: 'column',\n\t\tcontrols: {\n\t\t\ttype: 'snippet',\n\t\t\targ,\n\t\t\tsnippet,\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "src/routes/(app)/layout/setup-theme.svelte.ts",
    "content": "import { isSafari } from '$lib/helpers/utils/ua'\n\nconst updateThemeMetaElement = (element: Element) => {\n\t// Background color uses --surface color\n\tconst surfaceColor = window.getComputedStyle(document.documentElement).backgroundColor\n\telement.setAttribute('content', surfaceColor)\n}\n\nconst updateWindowTileBarColor = (isDark: boolean) => {\n\t// Safari does not respect media queries on theme meta element,\n\t// so instead we update all elements every time\n\tif (isSafari()) {\n\t\tconst metaTags = document.querySelectorAll('meta[name=\"theme-color\"]')\n\n\t\tfor (const element of metaTags) {\n\t\t\tupdateThemeMetaElement(element)\n\t\t}\n\n\t\treturn\n\t}\n\n\tconst element = document.querySelector(\n\t\t`meta[name=\"theme-color\"][media=\"(prefers-color-scheme: ${isDark ? 'dark' : 'light'})\"]`,\n\t)\n\n\tif (element) {\n\t\tupdateThemeMetaElement(element)\n\t}\n}\n\nexport const setupTheme = (): void => {\n\tconst player = usePlayer()\n\tconst mainStore = useMainStore()\n\n\t$effect.pre(() => {\n\t\tdocument.documentElement.classList.toggle('dark', mainStore.isThemeDark)\n\t})\n\n\tlet initial = true\n\t$effect.pre(() => {\n\t\tconst isDark = mainStore.isThemeDark\n\t\tconst artworkArgb = mainStore.pickColorFromArtwork\n\t\t\t? player.activeTrack?.primaryColor\n\t\t\t: undefined\n\n\t\tconst argbOrHex = artworkArgb ?? mainStore.customThemePaletteHex\n\n\t\tif (initial) {\n\t\t\tinitial = false\n\n\t\t\tif (isSafari()) {\n\t\t\t\tupdateWindowTileBarColor(isDark)\n\t\t\t}\n\n\t\t\t// On initial load, if no custom color is set we can skip\n\t\t\t// loading module which relatively heavy\n\t\t\tif (!argbOrHex) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tvoid import('$lib/theme.ts').then(({ updateThemeCssVariables }) => {\n\t\t\tupdateThemeCssVariables(argbOrHex, isDark)\n\t\t\tupdateWindowTileBarColor(isDark)\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Snapshot } from '@sveltejs/kit'\n\timport { goto } from '$app/navigation'\n\timport { page } from '$app/state'\n\timport type { RouteId } from '$app/types'\n\timport AlbumsListContainer from '$lib/components/AlbumsListContainer.svelte'\n\timport ArtistListContainer from '$lib/components/ArtistListContainer.svelte'\n\timport Button from '$lib/components/Button.svelte'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport type { IconType } from '$lib/components/icon/Icon.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport ListDetailsLayout from '$lib/components/ListDetailsLayout.svelte'\n\timport PlaylistListContainer from '$lib/components/playlists/PlaylistListContainer.svelte'\n\timport TracksListContainer from '$lib/components/tracks/TracksListContainer.svelte'\n\timport { initPageQueries } from '$lib/db/query/page-query.svelte.js'\n\timport { isMobile } from '$lib/helpers/utils/ua.ts'\n\timport { useSetOverlaySnippet } from '$lib/layout-bottom-bar.svelte.ts'\n\timport { FAVORITE_PLAYLIST_ID } from '$lib/library/playlists-actions.ts'\n\timport { getPlaylistMenuItems } from '$lib/menu-actions/playlists.ts'\n\timport Search from './Search.svelte'\n\n\tconst { data, children } = $props()\n\n\tinitPageQueries(() => data)\n\n\tconst main = useMainStore()\n\tconst dialogs = useDialogsStore()\n\n\tconst itemsIds = $derived(data.itemsIdsQuery.value)\n\tconst slug = $derived(data.slug)\n\tconst isHandHeldDevice = isMobile()\n\n\tinterface NavItem {\n\t\tslug: typeof slug\n\t\ttitle: string\n\t\ticon: IconType\n\t}\n\n\tconst navItems: NavItem[] = [\n\t\t{\n\t\t\tslug: 'tracks',\n\t\t\ttitle: m.tracks(),\n\t\t\ticon: 'musicNote',\n\t\t},\n\t\t{\n\t\t\tslug: 'albums',\n\t\t\ttitle: m.albums(),\n\t\t\ticon: 'album',\n\t\t},\n\t\t{\n\t\t\tslug: 'artists',\n\t\t\ttitle: m.artists(),\n\t\t\ticon: 'person',\n\t\t},\n\t\t{\n\t\t\tslug: 'playlists',\n\t\t\ttitle: m.playlists(),\n\t\t\ticon: 'playlist',\n\t\t},\n\t]\n\n\tconst isWideLayout = $derived(data.isWideLayout())\n\tconst layoutMode = $derived(\n\t\tdata.layoutMode(main.librarySplitLayoutEnabled, isWideLayout, page.params.uuid),\n\t)\n\n\tuseSetOverlaySnippet('bottom-bar', () => layoutBottom)\n\n\texport const snapshot: Snapshot<string> = {\n\t\tcapture: () => data.store.searchTerm,\n\t\trestore: (value) => {\n\t\t\tdata.store.searchTerm = value\n\t\t},\n\t}\n</script>\n\n{#snippet navItemsSnippet(className: string)}\n\t{#each navItems as item}\n\t\t<Button\n\t\t\tas=\"a\"\n\t\t\thref={`/library/${item.slug}`}\n\t\t\tkind=\"blank\"\n\t\t\ttooltip={item.title}\n\t\t\tclass={['flex shrink-0 items-center justify-center', className]}\n\t\t>\n\t\t\t<div\n\t\t\t\tclass={[\n\t\t\t\t\t'flex items-center justify-center rounded-full p-2',\n\t\t\t\t\titem.slug === slug && 'bg-secondaryContainer text-onSecondaryContainer',\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Icon type={item.icon} />\n\t\t\t</div>\n\t\t</Button>\n\t{/each}\n{/snippet}\n\n{#snippet layoutBottom()}\n\t{#if isHandHeldDevice}\n\t\t<div\n\t\t\tclass=\"pointer-events-auto grid h-16 w-full grid-cols-[repeat(auto-fit,minmax(0,1fr))] bg-surfaceContainer sm:hidden active-view-regular:view-name-[bottom-bar]\"\n\t\t>\n\t\t\t{@render navItemsSnippet('h-full')}\n\t\t</div>\n\t{/if}\n{/snippet}\n\n{#if layoutMode !== 'details'}\n\t<div\n\t\tclass={[\n\t\t\t'desktop-sidebar fixed z-1 mt-20 h-max w-max flex-col items-center gap-2 [@media(max-height:500px)]:mt-2',\n\t\t\tisHandHeldDevice ? 'hidden sm:flex' : 'flex',\n\t\t]}\n\t>\n\t\t{@render navItemsSnippet('h-14 w-20')}\n\n\t\t{#if (slug === 'albums' || slug === 'artists') && isWideLayout}\n\t\t\t<IconButton\n\t\t\t\ticon=\"sidePanel\"\n\t\t\t\ttooltip={main.librarySplitLayoutEnabled\n\t\t\t\t\t? m.librarySplitViewDisable()\n\t\t\t\t\t: m.librarySplitViewEnable()}\n\t\t\t\tclass={['mt-4', main.librarySplitLayoutEnabled && 'rotate-180']}\n\t\t\t\tonclick={() => {\n\t\t\t\t\tmain.librarySplitLayoutEnabled = !main.librarySplitLayoutEnabled\n\t\t\t\t}}\n\t\t\t/>\n\t\t{/if}\n\t</div>\n{/if}\n\n<ListDetailsLayout mode={layoutMode} class=\"mx-auto w-full max-w-(--app-max-content-width) grow\">\n\t{#snippet list(mode)}\n\t\t<div class={[isHandHeldDevice ? 'sm:pl-20' : 'pl-20', 'flex grow flex-col']}>\n\t\t\t<div class={[mode === 'both' && 'w-100', 'flex grow flex-col px-4']}>\n\t\t\t\t<Search name={data.pluralTitle()} sortOptions={data.sortOptions} store={data.store} />\n\n\t\t\t\t{#if slug === 'playlists'}\n\t\t\t\t\t<div class=\"mb-4 flex items-center justify-end\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tkind=\"outlined\"\n\t\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\t\tdialogs.openDialog('newPlaylist')\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Icon type=\"plus\" />\n\n\t\t\t\t\t\t\t{m.libraryNewPlaylist()}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t{#if data.tracksCountQuery.value === 0 && slug !== 'playlists'}\n\t\t\t\t\t<div class=\"my-auto flex flex-col items-center text-center\">\n\t\t\t\t\t\t<div class=\"mb-1 text-title-lg\">{m.libraryEmpty()}</div>\n\t\t\t\t\t\t{m.libraryStartByAdding()}\n\t\t\t\t\t\t<Button as=\"a\" href=\"/settings\" class=\"mt-4\">\n\t\t\t\t\t\t\t<Icon type=\"plus\" />\n\t\t\t\t\t\t\t{m.libraryImportTracks()}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t{:else}\n\t\t\t\t\t<div class={['flex w-full grow flex-col']}>\n\t\t\t\t\t\t{#if itemsIds.length === 0}\n\t\t\t\t\t\t\t<div class=\"relative m-auto flex flex-col items-center text-center\">\n\t\t\t\t\t\t\t\t<Icon type=\"magnify\" class=\"my-auto size-35 opacity-54\" />\n\n\t\t\t\t\t\t\t\t<div class=\"text-body-lg\">\n\t\t\t\t\t\t\t\t\t{m.libraryNoResults()}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t{m.libraryNoResultsExplanation()}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{:else if slug === 'tracks'}\n\t\t\t\t\t\t\t<TracksListContainer items={itemsIds} />\n\t\t\t\t\t\t{:else if slug === 'albums'}\n\t\t\t\t\t\t\t<AlbumsListContainer items={itemsIds} />\n\t\t\t\t\t\t{:else if slug === 'artists'}\n\t\t\t\t\t\t\t<ArtistListContainer items={itemsIds} />\n\t\t\t\t\t\t{:else if slug === 'playlists'}\n\t\t\t\t\t\t\t<PlaylistListContainer\n\t\t\t\t\t\t\t\titems={itemsIds}\n\t\t\t\t\t\t\t\tmenuItems={{\n\t\t\t\t\t\t\t\t\tdisabled: (playlist) => playlist.id === FAVORITE_PLAYLIST_ID,\n\t\t\t\t\t\t\t\t\titems: (playlist) => getPlaylistMenuItems(dialogs, playlist),\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonItemClick={({ playlist }) => {\n\t\t\t\t\t\t\t\t\tconst detailsViewId: RouteId = '/(app)/library/[[slug=libraryEntities]]/[uuid]'\n\t\t\t\t\t\t\t\t\tconst shouldReplace = page.route.id === detailsViewId\n\n\t\t\t\t\t\t\t\t\tvoid goto(`/library/playlists/${playlist.uuid}`, { replaceState: shouldReplace })\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t{/snippet}\n\n\t{#snippet details()}\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t'pointer-events-auto flex h-full flex-col rounded-3xl',\n\t\t\t\tlayoutMode === 'both' && 'mx-4 mt-4 border border-primary/5 bg-surfaceContainer',\n\t\t\t]}\n\t\t>\n\t\t\t{#key page.url.pathname}\n\t\t\t\t{@render children()}\n\t\t\t{/key}\n\t\t</div>\n\t{/snippet}\n</ListDetailsLayout>\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+layout.ts",
    "content": "import { redirect } from '@sveltejs/kit'\nimport { innerWidth } from 'svelte/reactivity/window'\nimport type { RouteId } from '$app/types'\nimport type { LayoutMode } from '$lib/components/ListDetailsLayout.svelte'\nimport { getLibraryItemIds } from '$lib/library/get/ids.ts'\nimport {\n\tcreateLibraryItemKeysPageQuery,\n\ttype PageQueryResult,\n} from '$lib/library/get/ids-queries.ts'\nimport { createTracksCountPageQuery } from '$lib/library/tracks-queries.ts'\nimport { FAVORITE_PLAYLIST_ID, type LibraryStoreName } from '$lib/library/types.ts'\nimport { getPersistedLibrarySplitLayoutEnabled } from '$lib/stores/main/store.svelte.ts'\nimport { defineViewTransitionMatcher } from '$lib/view-transitions.svelte.ts'\nimport type { LayoutLoad } from './$types.ts'\nimport { configsMap, type LibraryRouteConfig, type LibrarySearchFn } from './config.ts'\nimport { LibraryStore } from './store.svelte.ts'\n\nconst defaultSearchFn: LibrarySearchFn<{ name: string }> = (value, searchTerm) =>\n\tvalue.name.toLowerCase().includes(searchTerm)\n\ntype LoadDataResult<Slug extends LibraryStoreName> = {\n\t[ExactSlug in Slug]: LibraryRouteConfig<ExactSlug> & {\n\t\tstore: LibraryStore<ExactSlug>\n\t\titemsIdsQuery: PageQueryResult<number[]>\n\t\ttracksCountQuery: PageQueryResult<number>\n\t}\n}[Slug]\n\nconst loadData = async <Slug extends LibraryStoreName>(\n\tslug: Slug,\n): Promise<LoadDataResult<Slug>> => {\n\tconst config = configsMap[slug]\n\tconst searchFn = config.search ?? defaultSearchFn\n\tconst store = new LibraryStore(slug)\n\n\tconst itemsIdsQueryPromise = createLibraryItemKeysPageQuery(slug, {\n\t\tkey: () => [slug, store.sortByKey, store.order, store.searchTerm.toLowerCase().trim()],\n\t\tfetcher: async ([name, sortKey, order, searchTerm], signal) => {\n\t\t\tconst result = await getLibraryItemIds(name, {\n\t\t\t\tsort: sortKey,\n\t\t\t\torder,\n\t\t\t\tsearchTerm,\n\t\t\t\tsearchFn: (value) => searchFn(value, searchTerm),\n\t\t\t\tsignal,\n\t\t\t})\n\n\t\t\tif (slug === 'playlists') {\n\t\t\t\treturn [FAVORITE_PLAYLIST_ID, ...result]\n\t\t\t}\n\n\t\t\treturn result\n\t\t},\n\t})\n\n\tconst [itemsIdsQuery, tracksCountQuery] = await Promise.all([\n\t\titemsIdsQueryPromise,\n\t\tcreateTracksCountPageQuery(),\n\t])\n\n\treturn {\n\t\t...config,\n\t\tstore,\n\t\titemsIdsQuery,\n\t\ttracksCountQuery,\n\t}\n}\n\ntype LoadResult = LoadDataResult<LibraryStoreName> & {\n\tisWideLayout: () => boolean\n\tlayoutMode: (\n\t\tsplitViewAllowed: boolean,\n\t\tisWide: boolean,\n\t\titemId: string | undefined,\n\t) => LayoutMode\n}\n\nexport const load: LayoutLoad = async (event): Promise<LoadResult> => {\n\tconst { slug } = event.params\n\tif (!slug) {\n\t\tredirect(303, '/library/tracks')\n\t}\n\n\tconst data = await loadData(slug)\n\n\tconst isWideLayout = () => (innerWidth.current ?? 0) > 1154\n\t// We pass params here so that inside page we can benefit from $derived caching\n\tconst layoutMode = (\n\t\tsplitViewAllowed: boolean,\n\t\tisWide: boolean,\n\t\titemUuid: string | undefined,\n\t): LayoutMode => {\n\t\tif (slug === 'tracks') {\n\t\t\treturn 'list'\n\t\t}\n\n\t\tif (isWide && splitViewAllowed) {\n\t\t\treturn 'both'\n\t\t}\n\n\t\tif (itemUuid) {\n\t\t\treturn 'details'\n\t\t}\n\n\t\treturn 'list'\n\t}\n\n\tdefineViewTransitionMatcher((to, from) => {\n\t\tconst libraryRoute: RouteId = '/(app)/library/[[slug=libraryEntities]]'\n\t\tconst detailsRoute: RouteId = `${libraryRoute}/[uuid]`\n\n\t\tif (to === libraryRoute && from === libraryRoute) {\n\t\t\treturn { view: 'library' }\n\t\t}\n\n\t\tconst mode = event.untrack(() =>\n\t\t\tlayoutMode(getPersistedLibrarySplitLayoutEnabled(), isWideLayout(), event.params.uuid),\n\t\t)\n\t\tif (mode !== 'both') {\n\t\t\treturn null\n\t\t}\n\n\t\tif (\n\t\t\t(to === detailsRoute && from === libraryRoute) ||\n\t\t\t(to === libraryRoute && from === detailsRoute) ||\n\t\t\t(to === detailsRoute && from === detailsRoute)\n\t\t) {\n\t\t\treturn { view: 'library' }\n\t\t}\n\n\t\treturn null\n\t})\n\n\treturn {\n\t\t...data,\n\t\tisWideLayout,\n\t\tlayoutMode,\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+page.svelte",
    "content": "<script>\n\timport Icon from '$lib/components/icon/Icon.svelte'\n</script>\n\n<div class=\"m-auto flex flex-col items-center justify-center gap-4 py-10\">\n\t<Icon type=\"album\" class=\"my-auto size-35 opacity-54\" />\n\n\t<div class=\"relative flex flex-col\">\n\t\t<div class=\"max-w-50 text-center text-body-lg\">\n\t\t\t{m.librarySelectSomethingToBeShown()}\n\t\t</div>\n\n\t\t<!-- Down hand drawn arrow by kiddo from Noun Project (CC BY 3.0) -->\n\t\t<svg\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\tviewBox=\"0 0 100 125\"\n\t\t\tclass=\"absolute -bottom-30 -left-6 h-30 fill-current opacity-54\"\n\t\t>\n\t\t\t<path\n\t\t\t\td=\"M20.1 80.9c-.1.6-.2 2 .5 2 8.3.3 16.6 1 24.8 2.1.8.1 1.3-1 1.5-1.7.1-.5.4-2.1-.5-2.2-7.3-1-14.6-1.6-21.9-1.9 11.4-3.6 23-7.3 33-14 4.6-3.1 8.9-6.8 12.4-11.1 3.9-4.8 6.6-10.3 8.2-16.2 1.9-7.1 2.3-14.5 1.7-21.7 0-.6-.4-1.5-1.1-1-.7.5-1.1 1.7-1 2.6.4 5.9.3 12-1 17.8-1.2 5.4-3.4 10.3-6.8 14.7-6.8 8.7-16.5 14.5-26.5 18.6-5.4 2.2-10.9 4-16.5 5.8 2.7-4 4.7-8.5 5.9-13.2.1-.6.3-1.9-.5-2.2-.8-.2-1.3 1.2-1.5 1.7-1.6 6.5-5.1 12.4-10.1 17.1-.6.6-.9 1.9-.7 2.7 0 0 0 .1.1.1z\"\n\t\t\t/>\n\t\t</svg>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/Search.svelte",
    "content": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport MenuButton from '$lib/components/MenuButton.svelte'\n\timport Separator from '$lib/components/Separator.svelte'\n\timport { debounce } from '$lib/helpers/utils/debounce.ts'\n\timport { navigateToExternal } from '$lib/helpers/utils/navigate.ts'\n\timport type { PageData } from './$types.ts'\n\n\tinterface Props {\n\t\tname: string\n\t\tsortOptions: PageData['sortOptions']\n\t\tstore: PageData['store']\n\t}\n\n\tconst { name, sortOptions, store }: Props = $props()\n\n\tconst searchHandler = debounce((e: InputEvent) => {\n\t\tconst term = (e.target as HTMLInputElement).value\n\n\t\tstore.searchTerm = term\n\t\tqueueMicrotask(() => {\n\t\t\twindow.scrollTo({ top: 0, behavior: 'instant' })\n\t\t})\n\t}, 300)\n\n\tconst generalMenuItems = () => {\n\t\tconst menuItems = [\n\t\t\t{\n\t\t\t\tlabel: m.settings(),\n\t\t\t\taction: () => {\n\t\t\t\t\tgoto('/settings')\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.about(),\n\t\t\t\taction: () => {\n\t\t\t\t\tgoto('/about')\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.foundAnIssue(),\n\t\t\t\taction: () => {\n\t\t\t\t\tnavigateToExternal('https://github.com/minht11/local-music-pwa/issues/new')\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\n\t\treturn menuItems\n\t}\n\n\tconst sortMenuItems = $derived.by(() =>\n\t\tsortOptions().map((option) => ({\n\t\t\tlabel: option.name,\n\t\t\tselected: store.sortByKey === option.key,\n\t\t\taction: () => {\n\t\t\t\tstore.sortByKey = option.key\n\t\t\t},\n\t\t})),\n\t)\n</script>\n\n<div\n\tclass=\"@container sticky top-2 z-1 mt-2 mb-4 ml-auto flex w-full max-w-125 items-center gap-1 rounded-lg border border-primary/10 bg-surfaceContainerHighest px-2 @sm:gap-2\"\n>\n\t<input\n\t\tvalue={store.searchTerm}\n\t\ttype=\"text\"\n\t\tname=\"search\"\n\t\tplaceholder={`${m.librarySearch()} ${name.toLowerCase()}`}\n\t\tclass=\"h-12 w-60 grow bg-transparent pl-2 text-body-md placeholder:text-onSurface/54 focus:outline-none\"\n\t\toninput={(e) => searchHandler(e as unknown as InputEvent)}\n\t/>\n\n\t<Separator vertical class=\"my-auto hidden h-6 @sm:flex\" />\n\n\t{#if sortMenuItems.length > 1}\n\t\t<MenuButton icon=\"sort\" tooltip={m.libraryOpenSortMenu()} menuItems={sortMenuItems} />\n\t{/if}\n\n\t<IconButton\n\t\tclass={[store.order === 'desc' && 'rotate-180', 'transition-transform']}\n\t\ticon=\"sortAscending\"\n\t\ttooltip={m.libraryToggleSortOrder()}\n\t\tonclick={() => {\n\t\t\tstore.order = store.order === 'asc' ? 'desc' : 'asc'\n\t\t}}\n\t/>\n\n\t<Separator vertical class=\"my-auto hidden h-6 @sm:flex\" />\n\n\t<MenuButton\n\t\tariaLabel={m.libraryToggleSortOrder()}\n\t\ttooltip={m.libraryOpenApplicationMenu()}\n\t\tmenuItems={generalMenuItems}\n\t\twidth={200}\n\t/>\n</div>\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { MediaQuery } from 'svelte/reactivity'\n\timport Artwork from '$lib/components/Artwork.svelte'\n\timport Button from '$lib/components/Button.svelte'\n\timport Header from '$lib/components/Header.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport MenuButton from '$lib/components/MenuButton.svelte'\n\timport TracksListContainer from '$lib/components/tracks/TracksListContainer.svelte'\n\timport { initPageQueries } from '$lib/db/query/page-query.svelte.ts'\n\timport { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte'\n\timport { formatArtists, formatNameOrUnknown } from '$lib/helpers/utils/text.ts'\n\timport type { AlbumData, TrackData } from '$lib/library/get/value.ts'\n\timport {\n\t\tFAVORITE_PLAYLIST_ID,\n\t\tremoveTrackEntryFromPlaylist,\n\t} from '$lib/library/playlists-actions.ts'\n\timport { type Album, type Playlist, UNKNOWN_ITEM } from '$lib/library/types.ts'\n\timport { getPlaylistMenuItems } from '$lib/menu-actions/playlists.ts'\n\n\tconst { data } = $props()\n\n\tconst main = useMainStore()\n\tconst dialogs = useDialogsStore()\n\tconst player = usePlayer()\n\n\tinitPageQueries(() => data)\n\n\tconst item = $derived(data.itemQuery.value)\n\tconst tracks = $derived(data.tracksQuery.value)\n\tconst slug = $derived(data.slug)\n\n\tconst isFavoritesView = $derived(slug === 'playlists' && item.id === FAVORITE_PLAYLIST_ID)\n\n\tconst getFallbackArtwork = () => {\n\t\tif (slug === 'playlists') {\n\t\t\treturn 'playlist'\n\t\t}\n\n\t\tif (slug === 'albums') {\n\t\t\treturn 'album'\n\t\t}\n\n\t\treturn 'person'\n\t}\n\n\tconst artworkSrc = createManagedArtwork(() => {\n\t\tif (slug !== 'playlists') {\n\t\t\treturn (item as Album).image\n\t\t}\n\n\t\treturn null\n\t})\n\n\tconst isWideLayout = new MediaQuery('(min-width: 1154px)')\n\n\tconst playlistTrackMenuItems = (track: TrackData) => {\n\t\tif (isFavoritesView) {\n\t\t\treturn []\n\t\t}\n\n\t\treturn [\n\t\t\t{\n\t\t\t\tlabel: m.libraryTrackRemoveFromPlaylist(),\n\t\t\t\taction: () => {\n\t\t\t\t\tconst entryId = tracks.playlistIdMap?.[track.id]\n\t\t\t\t\tinvariant(entryId)\n\n\t\t\t\t\tvoid removeTrackEntryFromPlaylist(entryId)\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\t}\n\n\tconst getMenuItems = () => {\n\t\tconst addToQueueMenuItem =\n\t\t\ttracks.tracksIds.length === 0\n\t\t\t\t? null\n\t\t\t\t: {\n\t\t\t\t\t\tlabel: m.playerAddToQueue(),\n\t\t\t\t\t\taction: () => {\n\t\t\t\t\t\t\tplayer.addToQueue(tracks.tracksIds)\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\n\t\tif (slug === 'playlists') {\n\t\t\tif (isFavoritesView) {\n\t\t\t\treturn [addToQueueMenuItem]\n\t\t\t}\n\n\t\t\treturn [addToQueueMenuItem, ...getPlaylistMenuItems(dialogs, item as Playlist)]\n\t\t}\n\n\t\treturn [\n\t\t\taddToQueueMenuItem,\n\t\t\t{\n\t\t\t\tlabel: m.libraryAddToPlaylist(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('addToPlaylist', tracks.tracksIds)\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: m.libraryRemoveFromLibrary(),\n\t\t\t\taction: () => {\n\t\t\t\t\tdialogs.openDialog('removeFromLibrary', {\n\t\t\t\t\t\ttype: 'single',\n\t\t\t\t\t\tid: item.id,\n\t\t\t\t\t\tname: item.name,\n\t\t\t\t\t\tstoreName: slug,\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t]\n\t}\n\n\tconst menuItems = $derived.by(() => {\n\t\tconst items = getMenuItems().filter((item) => item !== null)\n\n\t\treturn items.length > 0 ? items : null\n\t})\n\n\tconst description = $derived(slug === 'playlists' && (item as Playlist).description)\n\n\tconst artists = $derived(slug === 'albums' && formatArtists((item as AlbumData).artists))\n</script>\n\n{#if !(isWideLayout.current && main.librarySplitLayoutEnabled)}\n\t<Header title={data.singularTitle()} />\n{/if}\n\n<div class=\"@container flex grow flex-col px-4 pb-4\">\n\t<section\n\t\tclass=\"relative flex w-full flex-col items-center justify-center gap-6 overflow-clip py-4 @2xl:min-h-60 @2xl:flex-row\"\n\t>\n\t\t{#if slug !== 'playlists'}\n\t\t\t<Artwork\n\t\t\t\tsrc={artworkSrc()}\n\t\t\t\tfallbackIcon={getFallbackArtwork()}\n\t\t\t\tclass=\"h-49 shrink-0 rounded-2xl @2xl:h-full\"\n\t\t\t/>\n\t\t{/if}\n\n\t\t<div\n\t\t\tclass=\"relative z-0 flex size-full flex-col overflow-clip rounded-2xl bg-surfaceContainerHigh\"\n\t\t>\n\t\t\t<div class=\"flex grow flex-col p-4\">\n\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t<Icon type=\"playlist\" class=\"size-10 text-onSurface/54\" />\n\n\t\t\t\t\t<h1 class=\"text-headline-md\">{formatNameOrUnknown(item.name)}</h1>\n\t\t\t\t</div>\n\n\t\t\t\t{#if description}\n\t\t\t\t\t<div class=\"text-body-lg\">{description}</div>\n\t\t\t\t{/if}\n\n\t\t\t\t{#if artists}\n\t\t\t\t\t<div class=\"grid w-full overflow-hidden text-body-lg\">\n\t\t\t\t\t\t<div class=\"truncate\">\n\t\t\t\t\t\t\t{artists}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t<div class=\"mt-1 text-onSurfaceVariant\">\n\t\t\t\t\t{#if slug === 'albums' && (item as AlbumData).year !== UNKNOWN_ITEM}\n\t\t\t\t\t\t{(item as AlbumData).year} •\n\t\t\t\t\t{/if}\n\n\t\t\t\t\t{m.libraryTracksCount({ count: tracks.tracksIds.length })}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"mt-auto flex items-center gap-2 py-4 pr-2 pl-4\">\n\t\t\t\t<Button\n\t\t\t\t\tkind=\"filled\"\n\t\t\t\t\tclass=\"my-1\"\n\t\t\t\t\tdisabled={tracks.tracksIds.length === 0}\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tplayer.playTrack(0, tracks.tracksIds)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{m.play()}\n\t\t\t\t</Button>\n\n\t\t\t\t<Button\n\t\t\t\t\tkind=\"flat\"\n\t\t\t\t\tclass=\"my-1 mr-auto\"\n\t\t\t\t\tdisabled={tracks.tracksIds.length === 0}\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tplayer.playTrack(0, tracks.tracksIds, {\n\t\t\t\t\t\t\tshuffle: true,\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{m.shuffle()}\n\t\t\t\t\t<Icon type=\"shuffle\" />\n\t\t\t\t</Button>\n\n\t\t\t\t{#if menuItems}\n\t\t\t\t\t<MenuButton tooltip={m.more()} menuItems={() => menuItems} />\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</section>\n\n\t<TracksListContainer\n\t\titems={tracks.tracksIds}\n\t\tpredefinedMenuItems={{\n\t\t\tdisableViewAlbum: slug === 'albums',\n\t\t\tdisableViewArtist: slug === 'artists',\n\t\t\tdisableAddToFavorites: isFavoritesView,\n\t\t\tenableMultiRemoveFromFavorites: isFavoritesView,\n\t\t}}\n\t\tmenuItems={slug === 'playlists' ? playlistTrackMenuItems : undefined}\n\t/>\n</div>\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.ts",
    "content": "import { error, redirect } from '@sveltejs/kit'\nimport { goto } from '$app/navigation'\nimport { type DbValue, getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts'\nimport { dbGetAlbumTracksIdsByName } from '$lib/library/get/ids.ts'\nimport { getLibraryValue } from '$lib/library/get/value.ts'\nimport {\n\tFAVORITE_PLAYLIST_ID,\n\tFAVORITE_PLAYLIST_UUID,\n\ttype LibraryStoreName,\n} from '$lib/library/types.ts'\nimport type { PageLoad } from './$types.d.ts'\n\ntype DetailsSlug = Exclude<LibraryStoreName, 'tracks'>\n\nconst createDetailsPageQuery = <T extends DetailsSlug>(\n\tstoreName: T,\n\tid: number,\n): Promise<PageQueryResult<DbValue<T>>> => {\n\tconst query = createPageQuery({\n\t\tkey: () => [storeName, id],\n\t\tfetcher: () => getLibraryValue(storeName, id),\n\t\tonDatabaseChange: (changes, actions) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === storeName && change.key === id) {\n\t\t\t\t\tif (change.operation === 'delete') {\n\t\t\t\t\t\tvoid goto(`/library/${storeName}`, { replaceState: true })\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\t// We always refetch because in most cases we would just hit Library Value Cache\n\t\t\t\t\tactions.refetch()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t})\n\n\treturn query\n}\n\nexport interface TracksQueryRegularResult {\n\ttracksIds: number[]\n\tplaylistIdMap: null\n}\n\nconst createTracksPageQuery = <Slug extends Exclude<DetailsSlug, 'playlists'>>(\n\tstoreName: Slug,\n\titemName: () => string,\n): Promise<PageQueryResult<TracksQueryRegularResult>> => {\n\tconst query = createPageQuery({\n\t\tkey: () => [storeName, itemName()],\n\t\tfetcher: async ([, name]): Promise<TracksQueryRegularResult> => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tlet keys: number[]\n\t\t\tif (storeName === 'albums') {\n\t\t\t\tkeys = await dbGetAlbumTracksIdsByName(name)\n\t\t\t} else {\n\t\t\t\tkeys = await db.getAllKeysFromIndex('tracks', 'artists', IDBKeyRange.only(name))\n\t\t\t}\n\n\t\t\treturn { tracksIds: keys, playlistIdMap: null }\n\t\t},\n\t\tonDatabaseChange: (changes, actions) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === 'tracks') {\n\t\t\t\t\t// We can't know the order\n\t\t\t\t\tactions.refetch()\n\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t})\n\n\treturn query\n}\n\nexport interface PlaylistTrackItem {\n\ttrackId: number\n\tuuid: string\n}\n\nexport interface PlaylistTracksQueryResult {\n\ttracksIds: number[]\n\tplaylistIdMap: Record<number, number>\n}\n\nconst createPlaylistTracksPageQuery = (\n\tplaylistId: number,\n): Promise<PageQueryResult<PlaylistTracksQueryResult>> => {\n\tconst query = createPageQuery({\n\t\tkey: () => [playlistId],\n\t\tfetcher: async (): Promise<PlaylistTracksQueryResult> => {\n\t\t\tconst db = await getDatabase()\n\n\t\t\tconst values = await db.getAllFromIndex('playlistEntries', 'playlistId', playlistId)\n\n\t\t\tconst tracksIds: number[] = Array.from({ length: values.length })\n\t\t\tconst playlistIdMap: Record<number, number> = {}\n\t\t\tfor (let i = 0; i < values.length; i += 1) {\n\t\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is always defined\n\t\t\t\tconst value = values[i]!\n\t\t\t\ttracksIds[i] = value.trackId\n\t\t\t\tplaylistIdMap[value.trackId] = value.id\n\t\t\t}\n\t\t\treturn { tracksIds, playlistIdMap }\n\t\t},\n\t\tonDatabaseChange: (changes, actions) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (\n\t\t\t\t\tchange.storeName === 'playlistEntries' &&\n\t\t\t\t\tchange.value.playlistId === playlistId\n\t\t\t\t) {\n\t\t\t\t\t// We can't know the order\n\t\t\t\t\tactions.refetch()\n\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t})\n\n\treturn query\n}\n\ninterface LoadResult {\n\tslug: DetailsSlug\n\titemQuery: PageQueryResult<DbValue<DetailsSlug>>\n\ttracksQuery: PageQueryResult<TracksQueryRegularResult | PlaylistTracksQueryResult>\n}\n\nexport const load: PageLoad = async (event): Promise<LoadResult> => {\n\tconst { slug } = event.params\n\tif (!slug || slug === 'tracks') {\n\t\tredirect(303, '/library/tracks')\n\t}\n\n\tconst uuid = event.params.uuid\n\tif (!uuid) {\n\t\terror(404)\n\t}\n\n\tlet id: number | undefined\n\tif (uuid === FAVORITE_PLAYLIST_UUID) {\n\t\tid = FAVORITE_PLAYLIST_ID\n\t} else {\n\t\tconst db = await getDatabase()\n\t\tid = await db.getKeyFromIndex(slug, 'uuid', uuid)\n\t}\n\n\tif (!id) {\n\t\terror(404)\n\t}\n\n\tconst itemQuery = await createDetailsPageQuery(slug, id)\n\tconst tracksQuery = await (slug === 'playlists'\n\t\t? createPlaylistTracksPageQuery(id)\n\t\t: createTracksPageQuery(slug, () => itemQuery.value.name))\n\n\treturn {\n\t\tslug,\n\t\titemQuery,\n\t\ttracksQuery,\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/config.ts",
    "content": "import type { DbValue } from '$lib/db/database.ts'\nimport type { LibraryItemSortKey } from '$lib/library/get/ids.ts'\nimport type { LibraryStoreName } from '$lib/library/types'\n\nexport type LibrarySearchFn<Value> = (value: Value, searchTerm: string) => boolean\n\nexport interface SortOption<Store extends LibraryStoreName> {\n\tname: string\n\tkey: LibraryItemSortKey<Store>\n}\n\nexport interface LibraryRouteConfig<Slug extends LibraryStoreName> {\n\tslug: Slug\n\tsingularTitle: () => string\n\tpluralTitle: () => string\n\tsearch?: LibrarySearchFn<DbValue<Slug>>\n\tsortOptions: () => SortOption<Slug>[]\n}\n\nconst includesTerm = (target: string | undefined | null, term: string) =>\n\ttarget?.toLowerCase().includes(term)\n\nconst artistsIncludesTerm = (item: { artists: string[] | undefined }, term: string) =>\n\titem.artists?.some((artist) => includesTerm(artist, term)) ?? false\n\nconst nameSortOption = {\n\tname: m.name(),\n\tkey: 'name',\n} as const\n\nconst trackConfig: LibraryRouteConfig<'tracks'> = {\n\tslug: 'tracks',\n\tsingularTitle: m.track,\n\tpluralTitle: m.tracks,\n\tsearch: (value, searchTerm) => {\n\t\tif (includesTerm(value.name, searchTerm)) {\n\t\t\treturn true\n\t\t}\n\n\t\tif (includesTerm(value.album, searchTerm)) {\n\t\t\treturn true\n\t\t}\n\n\t\tif (artistsIncludesTerm(value, searchTerm)) {\n\t\t\treturn true\n\t\t}\n\n\t\treturn false\n\t},\n\tsortOptions: () => [\n\t\tnameSortOption,\n\t\t{\n\t\t\tname: m.artist(),\n\t\t\tkey: 'artists',\n\t\t},\n\t\t{\n\t\t\tname: m.album(),\n\t\t\tkey: 'byAlbumSorted',\n\t\t},\n\t\t{\n\t\t\tname: m.duration(),\n\t\t\tkey: 'duration',\n\t\t},\n\t\t{\n\t\t\tname: m.year(),\n\t\t\tkey: 'year',\n\t\t},\n\t],\n}\n\nconst albumConfig: LibraryRouteConfig<'albums'> = {\n\tslug: 'albums',\n\tsingularTitle: m.album,\n\tpluralTitle: m.albums,\n\tsearch: (value, searchTerm) => {\n\t\tif (includesTerm(value.name, searchTerm)) {\n\t\t\treturn true\n\t\t}\n\n\t\tif (artistsIncludesTerm(value, searchTerm)) {\n\t\t\treturn true\n\t\t}\n\n\t\treturn false\n\t},\n\tsortOptions: () => [nameSortOption],\n}\n\nconst artistConfig: LibraryRouteConfig<'artists'> = {\n\tslug: 'artists',\n\tsingularTitle: m.artist,\n\tpluralTitle: m.artists,\n\tsortOptions: () => [nameSortOption],\n}\n\nconst playlistConfig: LibraryRouteConfig<'playlists'> = {\n\tslug: 'playlists',\n\tsingularTitle: m.playlist,\n\tpluralTitle: m.playlists,\n\tsortOptions: () => [nameSortOption, { name: m.created(), key: 'createdAt' }],\n}\n\ntype LibraryRouteConfigsMap = {\n\t[Slug in LibraryStoreName]: LibraryRouteConfig<Slug>\n}\n\nexport const configsMap: LibraryRouteConfigsMap = {\n\ttracks: trackConfig,\n\talbums: albumConfig,\n\tartists: artistConfig,\n\tplaylists: playlistConfig,\n}\n"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/store.svelte.ts",
    "content": "import { persist } from '$lib/helpers/persist.svelte.ts'\nimport type { LibraryItemSortKey, SortOrder } from '$lib/library/get/ids.ts'\nimport type { LibraryStoreName } from '$lib/library/types.ts'\n\nexport class LibraryStore<Slug extends LibraryStoreName> {\n\tsearchTerm: string = $state('')\n\n\torder: SortOrder = $state<SortOrder>('asc')\n\n\tsortByKey: LibraryItemSortKey<Slug> = $state('name')\n\n\tconstructor(slug: Slug) {\n\t\tpersist(`library:${slug}`, this, ['sortByKey', 'order'])\n\n\t\t// Previous version used 'album' key for sorting track by album.\n\t\t// Update value after loading persisted state.\n\t\tif (slug === 'tracks' && this.sortByKey === 'album') {\n\t\t\tthis.sortByKey = 'byAlbumSorted' as LibraryItemSortKey<Slug>\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/player/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport { page } from '$app/state'\n\timport BackButton from '$lib/components/BackButton.svelte'\n\timport Button from '$lib/components/Button.svelte'\n\timport Header from '$lib/components/Header.svelte'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport ListDetailsLayout from '$lib/components/ListDetailsLayout.svelte'\n\timport ActiveIndicator from '$lib/components/player/buttons/ActiveIndicator.svelte'\n\timport PlayerFavoriteButton from '$lib/components/player/buttons/PlayerFavoriteButton.svelte'\n\timport PlayNextButton from '$lib/components/player/buttons/PlayNextButton.svelte'\n\timport PlayPrevButton from '$lib/components/player/buttons/PlayPrevButton.svelte'\n\timport PlayTogglePillButton from '$lib/components/player/buttons/PlayTogglePillButton.svelte'\n\timport RepeatButton from '$lib/components/player/buttons/RepeatButton.svelte'\n\timport ShuffleButton from '$lib/components/player/buttons/ShuffleButton.svelte'\n\timport PlayerArtwork from '$lib/components/player/PlayerArtwork.svelte'\n\timport Timeline from '$lib/components/player/Timeline.svelte'\n\timport ScrollContainer from '$lib/components/ScrollContainer.svelte'\n\timport Slider from '$lib/components/Slider.svelte'\n\timport Tabs from '$lib/components/Tabs.svelte'\n\timport TracksListContainer from '$lib/components/tracks/TracksListContainer.svelte'\n\timport { initPageQueries } from '$lib/db/query/page-query.svelte.js'\n\timport { formatArtists, getItemLanguage } from '$lib/helpers/utils/text.ts'\n\timport { clearPlayHistory, dbRemoveFromPlayHistory } from '$lib/library/play-history-actions.js'\n\timport { getLayoutProps } from './layout-props.ts'\n\n\tconst { data } = $props()\n\n\tinitPageQueries(() => data)\n\n\tconst mainStore = useMainStore()\n\tconst player = usePlayer()\n\tconst dialogs = useDialogsStore()\n\tconst activeTrack = $derived(player.activeTrack)\n\n\tconst isSelectedTabQueue = $derived(\n\t\tpage.route.id === '/(app)/player' || page.route.id === '/(app)/player/queue',\n\t)\n\n\tconst { isCompactHorizontal, isCompactVertical, layoutMode } = $derived(\n\t\tgetLayoutProps(page.route.id),\n\t)\n</script>\n\n{#snippet playerSnippet()}\n\t<div\n\t\tclass={[\n\t\t\tlayoutMode === 'both' && 'w-100 2xl:w-[28dvw]',\n\t\t\tlayoutMode === 'list' && 'mx-auto w-full',\n\t\t\t'player-content z-0 grow items-center gap-x-6 overflow-clip bg-secondaryContainerVariant px-2 pb-2',\n\t\t\tisCompactVertical && !isCompactHorizontal && 'player-content-horizontal',\n\t\t]}\n\t>\n\t\t<div\n\t\t\tclass={[\n\t\t\t\tisCompactVertical && !isCompactHorizontal ? 'absolute top-0 left-0 h-14' : 'h-16',\n\t\t\t\t'flex w-full items-center justify-between gap-2 [grid-area:header]',\n\t\t\t]}\n\t\t>\n\t\t\t<BackButton />\n\n\t\t\t<div class=\"text-title-lg\">{m.player()}</div>\n\n\t\t\t<div class=\"w-10\"></div>\n\t\t</div>\n\n\t\t<PlayerArtwork\n\t\t\tclass=\"m-auto my-auto h-full max-h-75 rounded-2xl bg-onSecondary [grid-area:artwork] active-view-player:view-name-[pl-artwork]\"\n\t\t/>\n\n\t\t<div class=\"mt-2 flex w-full flex-col gap-2 [grid-area:controls]\">\n\t\t\t<div class=\"w-full rounded-2xl bg-surfaceContainerHighest px-4 py-2\">\n\t\t\t\t<Timeline class=\"w-full\" />\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclass={[\n\t\t\t\t\t'flex w-full flex-col gap-6 rounded-2xl bg-secondaryContainer px-4 [grid-area:header]',\n\t\t\t\t\tmainStore.volumeSliderEnabled ? 'pt-8 pb-4' : 'py-8',\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<div class=\"my-auto flex items-center justify-between gap-2\">\n\t\t\t\t\t<ShuffleButton />\n\n\t\t\t\t\t<PlayPrevButton />\n\n\t\t\t\t\t<PlayTogglePillButton />\n\n\t\t\t\t\t<PlayNextButton />\n\n\t\t\t\t\t<RepeatButton />\n\t\t\t\t</div>\n\n\t\t\t\t{#if mainStore.volumeSliderEnabled}\n\t\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon=\"volumeMid\"\n\t\t\t\t\t\t\ttooltip={m.playerDecreaseVolume()}\n\t\t\t\t\t\t\tonclick={() => (player.volume -= 10)}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<Slider bind:value={player.volume} />\n\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon=\"volumeHigh\"\n\t\t\t\t\t\t\ttooltip={m.playerIncreaseVolume()}\n\t\t\t\t\t\t\tonclick={() => (player.volume += 10)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\n\t\t\t<div class=\"flex h-18 w-full shrink-0 items-center rounded-2xl bg-secondaryContainer px-4\">\n\t\t\t\t{#if activeTrack}\n\t\t\t\t\t<div class=\"mr-2 min-w-6 text-center text-body-lg tabular-nums\">\n\t\t\t\t\t\t{player.activeTrackIndex + 1}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div class=\"grid overflow-hidden\" lang={getItemLanguage(activeTrack.language)}>\n\t\t\t\t\t\t<div class=\"truncate text-body-lg\">{activeTrack.name}</div>\n\t\t\t\t\t\t<div class=\"truncate text-body-md\">{formatArtists(activeTrack.artists)}</div>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t<div class=\"ml-auto flex gap-1\">\n\t\t\t\t\t<PlayerFavoriteButton />\n\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ttooltip={m.equalizerOpenEqualizer()}\n\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\tdialogs.openDialog('equalizer')\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Icon type=\"equalizer\" />\n\n\t\t\t\t\t\t<ActiveIndicator active={player.equalizer.enabled} />\n\t\t\t\t\t</IconButton>\n\n\t\t\t\t\t{#if layoutMode === 'list'}\n\t\t\t\t\t\t<IconButton tooltip={m.playerOpenQueue()} icon=\"trayFull\" as=\"a\" href=\"/player/queue\" />\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n{/snippet}\n\n{#snippet emptyList(title: string)}\n\t<div class=\"m-auto flex flex-col items-center text-center\">\n\t\t<Icon type=\"playlistMusic\" class=\"color-onSecondaryContainer my-auto size-35 opacity-54\" />\n\n\t\t<div class=\"mb-4 text-body-lg\">{title}</div>\n\t\t<Button kind=\"outlined\" as=\"a\" href=\"/library/tracks\">\n\t\t\t{m.playerQueuePlaySomething()}\n\t\t</Button>\n\t</div>\n{/snippet}\n\n{#snippet queueSnippet()}\n\t<!--\n\t\tFor view transition to work correctly we need to clip the captured element size\n\t\tso we can't use root scroller here.\n\t-->\n\t<ScrollContainer\n\t\tclass=\"flex h-dvh scroll-pt-(--app-header-height) flex-col overflow-auto contain-strict scrollbar-gutter-stable\"\n\t>\n\t\t<Header\n\t\t\tmode=\"sticky\"\n\t\t\tnoBackButton={layoutMode !== 'details'}\n\t\t\tclass={(isElevated) => [\n\t\t\t\t'border-b',\n\t\t\t\tisElevated ? 'border-transparent' : 'border-onSecondaryContainer/24',\n\t\t\t]}\n\t\t>\n\t\t\t<div class=\"absolute inset-0 m-auto size-max\">\n\t\t\t\t<Tabs\n\t\t\t\t\tselectedIndex={isSelectedTabQueue ? 0 : 1}\n\t\t\t\t\titems={[\n\t\t\t\t\t\t{ id: 'queue', text: m.queue() },\n\t\t\t\t\t\t{ id: 'history', text: m.playerHistory() },\n\t\t\t\t\t]}\n\t\t\t\t\tonchange={(item) => {\n\t\t\t\t\t\tvoid goto(`/player/${item.id}`, { replaceState: true })\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{#snippet text(item)}\n\t\t\t\t\t\t{item.text}\n\t\t\t\t\t{/snippet}\n\t\t\t\t</Tabs>\n\t\t\t</div>\n\n\t\t\t{#if isSelectedTabQueue}\n\t\t\t\t<IconButton\n\t\t\t\t\ttooltip={m.playerClearQueue()}\n\t\t\t\t\tdisabled={player.isQueueEmpty}\n\t\t\t\t\ticon=\"trayRemove\"\n\t\t\t\t\tonclick={player.clearQueue}\n\t\t\t\t/>\n\t\t\t{:else}\n\t\t\t\t<IconButton\n\t\t\t\t\ttooltip={m.playerClearHistory()}\n\t\t\t\t\tdisabled={data.historyTrackIds.value.length === 0}\n\t\t\t\t\ticon=\"trayRemove\"\n\t\t\t\t\tonclick={() => void clearPlayHistory()}\n\t\t\t\t/>\n\t\t\t{/if}\n\t\t</Header>\n\n\t\t<div class=\"mx-auto flex w-full max-w-(--app-max-content-width) grow flex-col\">\n\t\t\t<div class=\"flex grow p-4\">\n\t\t\t\t{#if isSelectedTabQueue}\n\t\t\t\t\t{#if player.isQueueEmpty}\n\t\t\t\t\t\t{@render emptyList(m.playerQueueEmpty())}\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<TracksListContainer\n\t\t\t\t\t\t\titems={player.itemsIds}\n\t\t\t\t\t\t\tshowReorderButton\n\t\t\t\t\t\t\tshowFavoriteButton={false}\n\t\t\t\t\t\t\tonReorder={(fromIndex, toIndex) => {\n\t\t\t\t\t\t\t\tplayer.moveQueueItem(fromIndex, toIndex)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tpredefinedMenuItems={{\n\t\t\t\t\t\t\t\tdisableAddToQueue: true,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tmenuItems={(_track, index) => [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tlabel: m.playerRemoveFromQueue(),\n\t\t\t\t\t\t\t\t\taction: () => {\n\t\t\t\t\t\t\t\t\t\tplayer.removeFromQueue(index)\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\tonItemClick={({ index }) => {\n\t\t\t\t\t\t\t\tplayer.playTrack(index)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t{/if}\n\t\t\t\t{:else if data.historyTrackIds.value.length === 0}\n\t\t\t\t\t{@render emptyList(m.playerHistoryEmpty())}\n\t\t\t\t{:else}\n\t\t\t\t\t<TracksListContainer\n\t\t\t\t\t\titems={data.historyTrackIds.value}\n\t\t\t\t\t\tmenuItems={(item) => [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tlabel: m.playerRemoveFromHistory(),\n\t\t\t\t\t\t\t\taction: () => {\n\t\t\t\t\t\t\t\t\tvoid dbRemoveFromPlayHistory(item.id)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tonItemClick={({ track }) => {\n\t\t\t\t\t\t\tconst trackIndexInQueue = player.itemsIds.indexOf(track.id)\n\t\t\t\t\t\t\tif (trackIndexInQueue !== -1) {\n\t\t\t\t\t\t\t\tplayer.playTrack(trackIndexInQueue)\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tplayer.playTrack(0, [track.id])\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</ScrollContainer>\n{/snippet}\n\n<ListDetailsLayout\n\tid=\"full-player\"\n\tmode={layoutMode}\n\tclass={[\n\t\t'grow active-view-player:view-name-[pl-card]',\n\t\tlayoutMode === 'both' && 'bg-secondaryContainer',\n\t]}\n\tlist={playerSnippet}\n\tdetails={queueSnippet}\n\tnoListStableGutter\n\tnoPlayerOverlayPadding\n/>\n\n<style lang=\"postcss\">\n\t@reference '../../../app.css';\n\n\t.player-content {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: 1fr;\n\t\tgrid-template-rows: max-content minmax(--spacing(35), 1fr) auto;\n\t\tgrid-template-areas: 'header' 'artwork' 'controls';\n\t}\n\n\t.player-content-horizontal {\n\t\tgrid-template-columns:\n\t\t\t1fr minmax(0, --spacing(75)) minmax(0, --spacing(125))\n\t\t\t1fr;\n\t\tgrid-template-rows: max-content 1fr;\n\t\tgrid-template-areas:\n\t\t\t'header header header header'\n\t\t\t'. artwork controls .';\n\t}\n\n\t@keyframes -global-view-player-container-rounded {\n\t\tfrom {\n\t\t\tborder-radius: var(--vt-pl-card-from-radius);\n\t\t}\n\t\tto {\n\t\t\tborder-radius: var(--vt-pl-card-to-radius);\n\t\t}\n\t}\n\n\t@keyframes -global-view-player-card-morph-enter {\n\t\tfrom {\n\t\t\twidth: var(--mp-width);\n\t\t\theight: var(--mp-height);\n\t\t\ttranslate: var(--mp-left) calc(var(--mp-bottom) - var(--mp-height));\n\t\t}\n\t\tto {\n\t\t\twidth: 100dvw;\n\t\t\theight: 100dvh;\n\t\t\ttranslate: 0 0;\n\t\t}\n\t}\n\n\t@keyframes -global-view-player-card-morph-exit {\n\t\tfrom {\n\t\t\twidth: 100dvw;\n\t\t\theight: 100dvh;\n\t\t\ttranslate: 0 0;\n\t\t}\n\t\tto {\n\t\t\twidth: var(--mp-width);\n\t\t\theight: var(--mp-height);\n\t\t\ttranslate: var(--mp-left) calc(var(--mp-bottom) - var(--mp-height));\n\t\t}\n\t}\n\n\t:global(html:active-view-transition-type(player)) {\n\t\t--vt-pl-card-radius: var(--radius-2xl);\n\t\t@media (width >= --theme(--breakpoint-sm)) {\n\t\t\t--vt-pl-card-radius: var(--radius-3xl);\n\t\t}\n\n\t\t&::view-transition-group(pl-card) {\n\t\t\toverflow: clip;\n\t\t\tbackground: var(--color-secondaryContainer);\n\t\t\ttop: 0;\n\t\t\tleft: 0;\n\t\t\ttransform: none;\n\t\t\theight: 100%;\n\t\t\tanimation:\n\t\t\t\tview-player-container-rounded 400ms var(--ease-standard) forwards,\n\t\t\t\tvar(--vt-pl-card-morph-ani) 400ms var(--ease-standard) forwards;\n\t\t}\n\n\t\t&::view-transition-old(pl-card),\n\t\t&::view-transition-new(pl-card) {\n\t\t\toverflow: clip;\n\t\t}\n\n\t\t&::view-transition-old(pl-card) {\n\t\t\tanimation: fade-out 75ms linear forwards;\n\t\t}\n\n\t\t&::view-transition-new(pl-card) {\n\t\t\tanimation: fade-in 325ms 75ms linear both;\n\t\t}\n\n\t\t&:active-view-transition-type(forwards) {\n\t\t\t--vt-pl-card-from-radius: var(--vt-pl-card-radius);\n\t\t\t--vt-pl-card-to-radius: 0;\n\t\t\t--vt-pl-card-morph-ani: view-player-card-morph-enter;\n\n\t\t\t&::view-transition-old(pl-card) {\n\t\t\t\tobject-fit: contain;\n\t\t\t}\n\n\t\t\t&::view-transition-new(pl-card) {\n\t\t\t\tobject-fit: cover;\n\t\t\t}\n\t\t}\n\n\t\t&:active-view-transition-type(backwards) {\n\t\t\t--vt-pl-card-from-radius: 0;\n\t\t\t--vt-pl-card-to-radius: var(--vt-pl-card-radius);\n\t\t\t--vt-pl-card-morph-ani: view-player-card-morph-exit;\n\n\t\t\t&::view-transition-old(pl-card) {\n\t\t\t\tobject-fit: cover;\n\t\t\t}\n\n\t\t\t&::view-transition-new(pl-card) {\n\t\t\t\tobject-fit: contain;\n\t\t\t}\n\t\t}\n\n\t\t&::view-transition-group(pl-artwork) {\n\t\t\tanimation-duration: 400ms;\n\t\t\tanimation-timing-function: var(--ease-standard);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(app)/player/+layout.ts",
    "content": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/page-query.svelte.ts'\nimport { defineViewTransitionMatcher } from '$lib/view-transitions.svelte.ts'\nimport type { LayoutLoad } from './$types.ts'\nimport { getLayoutProps } from './layout-props.ts'\n\ninterface LoadResult {\n\thistoryTrackIds: PageQueryResult<number[]>\n\tnoPlayerOverlay: boolean\n\thtmlOverflow: 'auto'\n}\n\nconst createPlayHistoryQuery = () =>\n\tcreatePageQuery({\n\t\tkey: [],\n\t\tfetcher: async () => {\n\t\t\tconst db = await getDatabase()\n\t\t\tconst entries = await db.getAllFromIndex('playHistory', 'playedAt')\n\n\t\t\treturn entries.map((entry) => entry.trackId).reverse()\n\t\t},\n\t\tonDatabaseChange: (changes, actions) => {\n\t\t\tfor (const change of changes) {\n\t\t\t\tif (change.storeName === 'playHistory') {\n\t\t\t\t\tvoid actions.refetch()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t})\n\nexport const load: LayoutLoad = async (): Promise<LoadResult> => {\n\tdefineViewTransitionMatcher((to, from) => {\n\t\tconst playerRouteId = '/(app)/player'\n\t\tconst prevRouteWasPlayer = from.startsWith(playerRouteId)\n\t\tconst nextRouteIsPlayer = to.startsWith(playerRouteId)\n\n\t\tif (prevRouteWasPlayer && nextRouteIsPlayer) {\n\t\t\tconst { layoutMode } = getLayoutProps(to)\n\n\t\t\tif (layoutMode === 'both' || (layoutMode === 'details' && from !== playerRouteId)) {\n\t\t\t\treturn { view: 'disabled' }\n\t\t\t}\n\n\t\t\t// Use default transition\n\t\t\treturn null\n\t\t}\n\n\t\tif (prevRouteWasPlayer) {\n\t\t\treturn { view: 'player', backwards: true }\n\t\t}\n\n\t\tif (nextRouteIsPlayer) {\n\t\t\treturn { view: 'player' }\n\t\t}\n\n\t\treturn null\n\t})\n\n\tconst historyTrackIds = await createPlayHistoryQuery()\n\n\treturn {\n\t\thistoryTrackIds,\n\t\tnoPlayerOverlay: true,\n\t\thtmlOverflow: 'auto',\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/player/+page.ts",
    "content": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): void => {}\n"
  },
  {
    "path": "src/routes/(app)/player/history/+page.ts",
    "content": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): void => {}\n"
  },
  {
    "path": "src/routes/(app)/player/layout-props.ts",
    "content": "import { innerHeight, innerWidth } from 'svelte/reactivity/window'\nimport type { RouteId } from '$app/types'\nimport type { LayoutMode } from '$lib/components/ListDetailsLayout.svelte'\n\nconst isRouteQueueOrHistory = (routeId: RouteId): boolean =>\n\trouteId === '/(app)/player/queue' || routeId === '/(app)/player/history'\n\nconst getLayoutMode = (isCompact: boolean, routeId: RouteId | null): LayoutMode => {\n\tif (!isCompact) {\n\t\treturn 'both'\n\t}\n\n\tif (routeId && isRouteQueueOrHistory(routeId)) {\n\t\treturn 'details'\n\t}\n\n\treturn 'list'\n}\n\nexport interface LayoutProps {\n\tisCompactVertical: boolean\n\tisCompactHorizontal: boolean\n\tisCompact: boolean\n\tlayoutMode: LayoutMode\n}\n\nexport const getLayoutProps = (routeId: RouteId | null): LayoutProps => {\n\tconst isCompactVertical = (innerHeight.current ?? 0) < 600\n\tconst isCompactHorizontal = (innerWidth.current ?? 0) < 768\n\tconst isCompact = isCompactVertical || isCompactHorizontal\n\n\treturn {\n\t\tisCompactVertical,\n\t\tisCompactHorizontal,\n\t\tisCompact,\n\t\tlayoutMode: getLayoutMode(isCompact, routeId),\n\t}\n}\n"
  },
  {
    "path": "src/routes/(app)/player/queue/+page.ts",
    "content": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): void => {}\n"
  },
  {
    "path": "src/routes/(assets)/icons/icon.server.ts",
    "content": "import { THEME_PALLETTE_LIGHT } from '../../../server/theme-colors.ts'\n\nconst foregroundColor = THEME_PALLETTE_LIGHT.onPrimary\n\nexport const getAppIcon = (clipBounds = false): string => `\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"512\" height=\"512\" viewBox=\"0 0 24 24\">\n    ${\n\t\tclipBounds\n\t\t\t? `<defs>\n                    <clipPath id=\"bounds\">\n                        <circle cx=\"12\" cy=\"12\" r=\"11.16\" />\n                    </clipPath>\n                </defs>\n            `\n\t\t\t: ''\n\t}\n    \n    <g ${clipBounds ? 'clip-path=\"url(#bounds)\"' : ''}>\n        <g id=\"foreground\">\n            <rect width=\"100%\" height=\"100%\" fill=\"${THEME_PALLETTE_LIGHT.primary}\" />\n\n            <rect width=\"2\" height=\"10\" fill=\"${foregroundColor}\" x=\"13\" y=\"6\" rx=\"1\" ry=\"1\" />\n            <rect width=\"6\" height=\"4\" fill=\"${foregroundColor}\" x=\"9\" y=\"13\" rx=\"1\" ry=\"1\" />\n        </g>\n    </g>\n\n    <style>\n        @media (max-width: 40px) {\n            #foreground {\n                transform: scale(1.4);\n                transform-origin:center;\n            }\n        }\n    </style>\n</svg>\n`\n"
  },
  {
    "path": "src/routes/(assets)/manifest.webmanifest/+server.ts",
    "content": "import { APP_DESCRIPTION_EN, APP_NAME_EN, APP_NAME_SHORT_EN } from '$lib/app-metadata.ts'\nimport { THEME_PALLETTE_DARK } from '../../../server/theme-colors.ts'\n\nexport const prerender = true\n\nconst manifest = {\n\tshort_name: APP_NAME_SHORT_EN,\n\tname: APP_NAME_EN,\n\tstart_url: './library/tracks/',\n\tscope: '../',\n\ttheme_color: THEME_PALLETTE_DARK.surface,\n\tbackground_color: THEME_PALLETTE_DARK.surface,\n\tdisplay: 'standalone',\n\torientation: 'any',\n\tdescription: APP_DESCRIPTION_EN,\n\ticons: [\n\t\t{\n\t\t\tsrc: '/icons/raster-192.png',\n\t\t\tsizes: '192x192',\n\t\t\ttype: 'image/png',\n\t\t\tpurpose: 'any',\n\t\t},\n\t\t{\n\t\t\tsrc: '/icons/responsive.svg',\n\t\t\ttype: 'image/svg+xml',\n\t\t\tsizes: 'any',\n\t\t\tpurpose: 'any',\n\t\t},\n\t\t{\n\t\t\tsrc: '/icons/maskable.svg',\n\t\t\ttype: 'image/svg+xml',\n\t\t\tsizes: 'any',\n\t\t\tpurpose: 'maskable',\n\t\t},\n\t],\n}\n\nexport const GET = () =>\n\tnew Response(JSON.stringify(manifest), {\n\t\theaders: {\n\t\t\t'Content-Type': 'application/manifest+json',\n\t\t},\n\t})\n"
  },
  {
    "path": "src/routes/(marketing)/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from '$app/state'\n\timport { APP_DESCRIPTION_EN, APP_NAME_EN } from '$lib/app-metadata.ts'\n\timport Button from '$lib/components/Button.svelte'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport heroImg from './assets/hero.avif?as=metadata'\n\timport FeaturesSection from './components/FeaturesSection.svelte'\n\timport GettingStartedSection from './components/GettingStartedSection.svelte'\n\timport HeroSection from './components/HeroSection.svelte'\n\timport HowItWorksSection from './components/HowItWorksSection.svelte'\n\timport SoundControlsSection from './components/SoundControlsSection.svelte'\n\n\tconst seoTitle = `${APP_NAME_EN} - Private offline local music player in your browser`\n\tconst seoDescription = APP_DESCRIPTION_EN\n\n\tconst trackOpenPlayerClick = (location: 'header' | 'hero' | 'getting-started') => {\n\t\twindow.goatcounter?.count({\n\t\t\tpath: `click-marketing-open-player-${location}`,\n\t\t\ttitle: `Clicked marketing Open Player (${location})`,\n\t\t\tevent: true,\n\t\t})\n\t}\n\n\tconst schemaJson = $derived(\n\t\tJSON.stringify(\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t'@context': 'https://schema.org',\n\t\t\t\t\t'@type': 'WebApplication',\n\t\t\t\t\tname: APP_NAME_EN,\n\t\t\t\t\tapplicationCategory: 'MultimediaApplication',\n\t\t\t\t\tapplicationSubCategory: 'Music Player',\n\t\t\t\t\toperatingSystem: 'Any',\n\t\t\t\t\tbrowserRequirements: 'Requires a modern web browser',\n\t\t\t\t\tdescription: seoDescription,\n\t\t\t\t\turl: page.url.href,\n\t\t\t\t\timage: `${page.url.origin}${heroImg.src}`,\n\t\t\t\t\toffers: {\n\t\t\t\t\t\t'@type': 'Offer',\n\t\t\t\t\t\tprice: '0',\n\t\t\t\t\t\tpriceCurrency: 'USD',\n\t\t\t\t\t},\n\t\t\t\t\tsameAs: ['https://github.com/minht11/local-music-pwa'],\n\t\t\t\t\tfeatureList: [\n\t\t\t\t\t\t'Play music stored on your device',\n\t\t\t\t\t\t'Works in modern browsers on Android and iOS, plus Chromebooks, Windows PCs, and Macs',\n\t\t\t\t\t\t'Works offline with no account or uploads',\n\t\t\t\t\t\t'Organizes tracks into albums, artists, playlists, and favorites',\n\t\t\t\t\t\t'Includes queue, shuffle, repeat, history, equalizer, and playback speed controls',\n\t\t\t\t\t\t'Artwork colors adapt to your music',\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t\tnull,\n\t\t\t0,\n\t\t),\n\t)\n</script>\n\n<svelte:head>\n\t<title>{seoTitle}</title>\n\t<meta name=\"description\" content={seoDescription} />\n\t<meta\n\t\tname=\"keywords\"\n\t\tcontent=\"local music player, offline music player, browser music player, android music player, ios music player, chromebook music player, windows music player, macos music player, play music from device, private music player, playlists, queue, favorites, equalizer, playback speed control\"\n\t/>\n\t<meta name=\"robots\" content=\"index,follow,max-image-preview:large\" />\n\t<meta name=\"application-name\" content={APP_NAME_EN} />\n\t<meta property=\"og:type\" content=\"website\" />\n\t<meta property=\"og:title\" content={seoTitle} />\n\t<meta property=\"og:description\" content={seoDescription} />\n\t<meta property=\"og:url\" content={page.url.href} />\n\t<meta property=\"og:image\" content={`${page.url.origin}${heroImg.src}`} />\n\t<meta property=\"og:image:alt\" content=\"Snae Player showing the library and player interface\" />\n\t<meta name=\"twitter:card\" content=\"summary_large_image\" />\n\t<meta name=\"twitter:title\" content={seoTitle} />\n\t<meta name=\"twitter:description\" content={seoDescription} />\n\t<meta name=\"twitter:image\" content={`${page.url.origin}${heroImg.src}`} />\n\n\t<link rel=\"canonical\" href={`${page.url.origin}${page.url.pathname}`} />\n\n\t{@html `<script type=\"application/ld+json\">${schemaJson}</script>`}\n</svelte:head>\n\n<header class=\"mktg-content-width flex-row justify-start gap-2 py-4\">\n\t<div\n\t\tclass=\"mr-auto flex items-center gap-2 text-title-sm font-medium text-onSurface xs:text-title-md\"\n\t>\n\t\t<img src=\"/icons/responsive.svg\" width=\"24\" height=\"24\" alt=\"\" class=\"size-6\" />\n\t\t{m.appName()}\n\t</div>\n\n\t<IconButton\n\t\tas=\"a\"\n\t\thref=\"https://github.com/minht11/local-music-pwa\"\n\t\ttarget=\"_blank\"\n\t\tkind=\"flat\"\n\t\ttooltip=\"View source code on GitHub\"\n\t>\n\t\t<Icon type=\"github\" />\n\t</IconButton>\n\n\t<Button\n\t\tas=\"a\"\n\t\thref=\"/library/tracks\"\n\t\tkind=\"outlined\"\n\t\tclass=\"max-sm:hidden\"\n\t\tonclick={() => trackOpenPlayerClick('header')}\n\t>\n\t\tOpen Player\n\t</Button>\n</header>\n\n<main class=\"flex flex-col gap-14 select-text md:gap-32\">\n\t<HeroSection onOpenPlayerClick={() => trackOpenPlayerClick('hero')} />\n\n\t<HowItWorksSection />\n\n\t<FeaturesSection />\n\n\t<SoundControlsSection />\n\n\t<GettingStartedSection onOpenPlayerClick={() => trackOpenPlayerClick('getting-started')} />\n</main>\n\n<footer class=\"w-full border-t border-outlineVariant bg-shadow/7\">\n\t<div class=\"mktg-content-width items-center justify-between gap-4 py-8 sm:flex-row\">\n\t\t<div class=\"flex items-center gap-2 text-label-lg font-medium text-onSurfaceVariant\">\n\t\t\t<img\n\t\t\t\tsrc=\"/icons/responsive.svg\"\n\t\t\t\twidth=\"24\"\n\t\t\t\theight=\"24\"\n\t\t\t\talt=\"Logo\"\n\t\t\t\tclass=\"size-5 opacity-60\"\n\t\t\t/>\n\t\t\t{m.appName()}\n\t\t</div>\n\n\t\t<div class=\"flex items-center gap-6 text-body-md\">\n\t\t\t<a\n\t\t\t\thref=\"https://github.com/minht11/local-music-pwa\"\n\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"flex items-center gap-1.5 text-onSurfaceVariant transition-colors duration-200 hover:text-onSurface\"\n\t\t\t>\n\t\t\t\t<Icon type=\"github\" class=\"h-4 w-4\" />\n\t\t\t\t{m.aboutSourceCode()}\n\t\t\t</a>\n\n\t\t\t<a\n\t\t\t\thref=\"https://github.com/minht11/local-music-pwa#privacy\"\n\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"text-onSurfaceVariant transition-colors duration-200 hover:text-onSurface\"\n\t\t\t>\n\t\t\t\t{m.aboutPrivacy()}\n\t\t\t</a>\n\t\t</div>\n\t</div>\n</footer>\n"
  },
  {
    "path": "src/routes/(marketing)/+page.ts",
    "content": "import '../../app.css'\n\nexport const ssr = true\nexport const prerender = true\nexport const csr = true\n"
  },
  {
    "path": "src/routes/(marketing)/AGENTS.md",
    "content": "# Marketing Route Guidance\n\nWhen editing the landing page under `src/routes/(marketing)/`, follow `TONE_OF_VOICE.md`.\n\nTreat it as the source of truth for:\n\n- wording and section tone\n- how concrete or technical copy should be\n- what kinds of marketing phrases to avoid\n- how to keep sections distinct and non-duplicative\n\nDefault behavior:\n\n- prefer the clearer version over the cleverer version\n- match product terminology exactly when possible\n- cut lines that sound generic, cliche, or duplicative\n"
  },
  {
    "path": "src/routes/(marketing)/TONE_OF_VOICE.md",
    "content": "# Marketing Tone Of Voice\n\nUse this guide for all copy on the landing page and other marketing-facing surfaces for Snae Player.\n\n## Voice In One Paragraph\n\nWrite 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.\"\n\n## Product Positioning\n\nKeep new copy anchored in these points:\n\n- local music player\n- in the browser\n- private by default\n- usable without sign-up or uploads\n- built around real listening features like playlists, queue, favorites, equalizer, and playback speed\n\nIf 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.\n\n## Tone By Layer\n\nNot every part of the page should sound equally playful.\n\nUse more warmth in:\n\n- headlines\n- section labels\n- short framing lines\n- feature names when they still map cleanly to real behavior\n- feature descriptions when they describe a real listening benefit in plain language\n\nStay more literal in:\n\n- hero supporting copy\n- privacy and permissions language\n- onboarding and setup steps\n- browser support notes\n- any sentence that sounds like a promise or guarantee\n\nSimple rule: the closer a line is to a claim, instruction, or guarantee, the more exact it should be.\n\nAnother 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.\n\n## Non-Negotiable Rules\n\nUse this order when making copy decisions:\n\n1. Write about what the app actually does.\n2. Use the labels users actually see in the app whenever possible.\n3. Make every meaningful claim easy to defend from the product.\n\nPrefer:\n\n- \"Open a music folder or pick individual tracks\"\n- \"Add a directory\"\n- \"Tracks, albums, and artists are organized automatically\"\n- \"No sign-up, no cloud sync, and no download required\"\n- \"Built around your music\"\n- \"Colors that follow your music\"\n- \"Adjust the sound, set your pace\"\n- \"Listen at any speed\"\n- \"Build playlists for any mood\"\n\nAvoid:\n\n- \"Seamless audio experiences\"\n- \"Powerful music management\"\n- \"Next-generation playback\"\n- \"Built for people who love music\"\n\nIf a sentence could apply to almost any music app, it is probably too vague.\n\nThat 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.\n\n### Use Real Labels\n\nUse the labels users actually see in the app whenever possible.\n\n- Use \"Add directory\" because that is the real action in Settings.\n- Use \"Open Player\" if that is the actual CTA.\n- Refer to equalizer, playback speed, playlists, queue, and favorites exactly as they exist in product UI.\n\nDo not invent nicer-sounding labels if they create distance from the real product.\n\n### Defend Every Claim\n\nEvery meaningful claim should be easy to defend from the product.\n\nThis includes implication, not just literal wording. A line should not suggest a capability, convenience, or privacy guarantee that the product does not clearly provide.\n\nGood:\n\n- \"Your files stay on your device\"\n- \"Works offline\"\n- \"The browser asks before giving access\"\n\nWeak or risky:\n\n- \"Complete control over your music\"\n- \"The best way to manage local audio\"\n- \"Ultimate privacy\"\n\nIf a claim feels broad, narrow it until it is plainly true.\n\nWhen 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.\n\n### Explain Privacy Calmly\n\nPrivacy matters, but the tone should stay matter-of-fact.\n\nPrefer:\n\n- \"No sign-up, no cloud sync\"\n- \"The browser asks before giving access\"\n- \"Library data stays on this device\"\n\nAvoid:\n\n- fear-based language\n- dramatic warnings\n- sounding anti-technology or anti-cloud in general\n\nThe message is that the app is private because of how it works, not because it is making ideological speeches.\n\nDo not lean on empty emotional framing.\n\nAvoid lines like:\n\n- \"Made for people who love music\"\n- \"Built for audiophiles and casual listeners alike\"\n- \"Bring your music to life\"\n\nThese add tone without adding information.\n\nUse this distinction:\n\n- \"Built around your music\" is acceptable because it frames a real product direction.\n- \"Bring your music to life\" is weak because it adds mood without saying anything concrete.\n\n### Do Not Drift Into Internals\n\nThe page should explain enough to build trust, but should not drift into implementation detail.\n\nGood:\n\n- \"Pick a folder, approve access in the browser\"\n- \"Works in all modern browsers\"\n\nAvoid unless truly needed:\n\n- storage engine names\n- browser API names in body copy\n- internal implementation detail like how files are copied or persisted\n\nTechnical detail is acceptable only when it directly helps trust or clarity.\n\n### Keep Sentences Short And Useful\n\nLanding-page copy should scan quickly.\n\n- Lead with the main point.\n- Keep descriptions compact.\n- Remove filler transitions.\n- Cut any sentence that repeats the section title without adding information.\n\n### Give Each Section One Job\n\n- Do not restate the same benefit in multiple sections.\n- If a section repeats another section with softer wording, cut or rewrite it.\n\n## Preferred Patterns\n\n### Headlines\n\n- Keep them short.\n- Make them product-first.\n- Mild slogan energy is fine if the meaning stays clear.\n- Avoid metaphor or brand-language that hides what the product actually does.\n\n### Descriptions\n\n- Keep them to one concrete action or benefit.\n- Prefer one strong sentence over two softer ones.\n- Explain what the user can do or what the app guarantees.\n- If a heading is slightly playful, let the description do the grounding.\n- Benefit-first phrasing is acceptable when the related feature is named nearby and the implication stays modest.\n\n### CTA Copy\n\n- Match real UI labels.\n- Prefer direct verbs.\n- Do not invent more polished alternatives if the in-product label is already clear.\n- If the landing page CTA leads to setup rather than immediate use, the wording should not imply instant playback.\n\n### Trust Copy\n\n- Explain permission, privacy, and browser behavior plainly.\n- Describe behavior, not philosophy.\n\n## Project Preferences\n\n- Prefer direct phrasing over polished marketing phrasing.\n- Prefer \"concrete and trustworthy\" over \"clever and branded\".\n- Allow a small amount of playful or warm wording in headings and section framing.\n- Do not overcorrect into overly strict or sterile copy.\n- Do not rewrite solid existing copy just to make it sound more literal if it is already truthful, specific enough, and product-shaped.\n- Avoid cliche lines immediately if they feel generic.\n- Use fewer section descriptions when the section title and content already carry the idea.\n- Use browser-support language in a practical way, not as a technical brag.\n- If a phrase sounds like it could belong on any startup landing page, rewrite it.\n\n## Example Rewrites\n\nStronger:\n\n- \"Open a music folder or pick individual tracks, then start listening right away.\"\n- \"The browser asks before giving access.\"\n- \"No sign-up, no cloud sync, and no download required.\"\n- \"Play music from your device.\"\n- \"Built around your music.\"\n- \"Colors that follow your music.\"\n- \"Up and running in seconds.\"\n- \"A clean player for your local music, with playlists, sound controls, and offline listening.\"\n- \"Build playlists for any mood, star what you love, and line up what plays next.\"\n- \"Adjust the sound, set your pace.\"\n- \"Listen at any speed.\"\n\nWeaker:\n\n- \"Enjoy seamless playback from your personal collection.\"\n- \"Security comes first in our architecture.\"\n- \"A delightful listening experience tailored to you.\"\n- \"Built for people who love music.\"\n- \"Experience music like never before.\"\n\n## Quick Copy Checklist\n\nBefore finalizing new marketing copy, check:\n\n- Is this wording specific to Snae Player?\n- Does it match real product behavior and labels?\n- Is it truthful in implication, not just literally defensible?\n- Is it saying something new, or repeating another section?\n- Can the sentence be shortened without losing meaning?\n- Does it sound calm and credible?\n- Would a skeptical user believe it immediately?\n\n## Default Rule\n\nWhen choosing between:\n\n- more clever vs more clear\n- more branded vs more specific\n- more polished vs more believable\n\nchoose the clearer, more specific, more believable version.\n\nIf both versions are equally true and clear, it is okay to choose the one with a little more warmth.\n"
  },
  {
    "path": "src/routes/(marketing)/components/FeaturesSection.svelte",
    "content": "<script lang=\"ts\">\n\timport Icon, { type IconType } from '$lib/components/icon/Icon.svelte'\n\timport Section from './Section.svelte'\n\n\tinterface Feature {\n\t\ticon: IconType\n\t\ttitle: string\n\t\tdescription: string\n\t}\n\n\tconst features: Feature[] = [\n\t\t{\n\t\t\ticon: 'folder',\n\t\t\ttitle: 'Play music from your device',\n\t\t\tdescription:\n\t\t\t\t'Open a music folder or pick tracks, then keep listening across phones, tablets, laptops, and desktops.',\n\t\t},\n\t\t{\n\t\t\ticon: 'album',\n\t\t\ttitle: 'Tracks, albums, and artists sorted for you',\n\t\t\tdescription:\n\t\t\t\t'Your library is organized from file metadata, so it is easy to browse by track, album, or artist.',\n\t\t},\n\t\t{\n\t\t\ticon: 'playlistMusic',\n\t\t\ttitle: 'Queue, favorites, and listening history',\n\t\t\tdescription:\n\t\t\t\t'Save favorites, revisit what you played, and control what comes next with queue, shuffle, and repeat.',\n\t\t},\n\t\t{\n\t\t\ticon: 'palette',\n\t\t\ttitle: 'Colors that follow your music',\n\t\t\tdescription:\n\t\t\t\t\"The interface picks up colors from your album artwork and adapts to your system's light or dark theme.\",\n\t\t},\n\t]\n</script>\n\n<Section\n\tid=\"features\"\n\tclass=\"mktg-content-width\"\n\tlabel=\"What's inside\"\n\ttitle=\"Built around your music\"\n>\n\t<div class=\"grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-2\">\n\t\t{#each features as feature}\n\t\t\t<article class=\"feature-card marketing-scroll-enter-soft p-7\">\n\t\t\t\t<div class=\"feature-card-icon mb-5\">\n\t\t\t\t\t<Icon type={feature.icon} />\n\t\t\t\t</div>\n\t\t\t\t<h3 class=\"mb-2 text-title-lg font-semibold text-onSurface\">\n\t\t\t\t\t{feature.title}\n\t\t\t\t</h3>\n\t\t\t\t<p class=\"text-body-lg leading-relaxed text-onSurfaceVariant\">\n\t\t\t\t\t{feature.description}\n\t\t\t\t</p>\n\t\t\t\t<span class=\"feature-card-deco\"><Icon type={feature.icon} /></span>\n\t\t\t</article>\n\t\t{/each}\n\t</div>\n</Section>\n\n<style>\n\t.feature-card {\n\t\tposition: relative;\n\t\toverflow: hidden;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tborder-radius: 1.75rem;\n\t\tborder: 1px solid --alpha(var(--color-outline) / 0.2);\n\t\tbackground: linear-gradient(\n\t\t\t155deg,\n\t\t\t--alpha(var(--color-surfaceContainerHighest) / 0.97),\n\t\t\t--alpha(var(--color-surfaceContainerHigh) / 0.9)\n\t\t);\n\t\tbox-shadow: 0 4px 16px --alpha(var(--color-shadow) / 0.06);\n\t\ttransition:\n\t\t\tborder-color 0.2s ease,\n\t\t\tbox-shadow 0.2s ease;\n\t}\n\n\t.feature-card:hover {\n\t\tborder-color: --alpha(var(--color-primary) / 0.4);\n\t\tbox-shadow: 0 8px 28px --alpha(var(--color-shadow) / 0.1);\n\t}\n\n\t.feature-card-icon {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\twidth: 3.25rem;\n\t\theight: 3.25rem;\n\t\tborder-radius: 1rem;\n\t\tbackground: --alpha(var(--color-primaryContainer) / 0.55);\n\t\tcolor: var(--color-primary);\n\t}\n\n\t.feature-card-deco {\n\t\tposition: absolute;\n\t\tbottom: -0.75rem;\n\t\tright: -0.5rem;\n\t\twidth: 6.5rem;\n\t\theight: 6.5rem;\n\t\tcolor: var(--color-primary);\n\t\topacity: 0.06;\n\t\tpointer-events: none;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t}\n\n\t.feature-card-deco :global(svg) {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(marketing)/components/GettingStartedSection.svelte",
    "content": "<script lang=\"ts\">\n\timport Button from '$lib/components/Button.svelte'\n\timport Section from './Section.svelte'\n\n\tinterface Props {\n\t\tonOpenPlayerClick: (event: MouseEvent) => void\n\t}\n\n\tinterface Step {\n\t\ttitle: string\n\t\tdescription: string\n\t}\n\n\tconst { onOpenPlayerClick }: Props = $props()\n\n\tconst steps: Step[] = [\n\t\t{\n\t\t\ttitle: 'Add a directory',\n\t\t\tdescription:\n\t\t\t\t'In Settings, click Add directory and choose the music folder you want Snae Player to scan.',\n\t\t},\n\t\t{\n\t\t\ttitle: 'Browse your library',\n\t\t\tdescription:\n\t\t\t\t'Tracks, albums, and artists are organized automatically from your file metadata.',\n\t\t},\n\t\t{\n\t\t\ttitle: 'Set up what plays next',\n\t\t\tdescription: 'Build a playlist, queue a few tracks, mark favorites, and press play.',\n\t\t},\n\t]\n</script>\n\n<div class=\"bg-shadow/7 py-14 md:py-20\">\n\t<Section\n\t\tclass=\"mktg-content-width\"\n\t\tlabel=\"Getting started\"\n\t\ttitle=\"Start listening in three steps\"\n\t>\n\t\t<div class=\"mx-auto grid max-w-4xl gap-6 md:grid-cols-3\">\n\t\t\t{#each steps as step, i}\n\t\t\t\t<div\n\t\t\t\t\tclass=\"marketing-scroll-enter-soft flex flex-col rounded-3xl border border-outlineVariant p-7\"\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"flex size-8 items-center justify-center rounded-full bg-primaryContainer/60 text-body-lg text-primary\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{i + 1}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<h3 class=\"mt-4 mb-2 text-title-lg font-semibold text-onSurface\">{step.title}</h3>\n\n\t\t\t\t\t<p class=\"text-body-lg leading-relaxed text-onSurfaceVariant\">{step.description}</p>\n\t\t\t\t</div>\n\t\t\t{/each}\n\t\t</div>\n\n\t\t<p class=\"marketing-scroll-enter-soft mt-4 text-center text-body-md text-onSurfaceVariant\">\n\t\t\tWorks in modern browsers on Android and iOS, plus Chromebooks, Windows PCs, and Macs.\n\t\t</p>\n\n\t\t<Button\n\t\t\tas=\"a\"\n\t\t\thref=\"/library/tracks\"\n\t\t\tkind=\"filled\"\n\t\t\tclass=\"marketing-scroll-enter-soft mt-10 w-full sm:w-60\"\n\t\t\tonclick={onOpenPlayerClick}\n\t\t>\n\t\t\tOpen Player\n\t\t</Button>\n\t</Section>\n</div>\n"
  },
  {
    "path": "src/routes/(marketing)/components/HeroSection.svelte",
    "content": "<script lang=\"ts\">\n\timport Button from '$lib/components/Button.svelte'\n\timport heroImg from '../assets/hero.avif?as=metadata'\n\n\tinterface Props {\n\t\tonOpenPlayerClick: (event: MouseEvent) => void\n\t}\n\n\tconst { onOpenPlayerClick }: Props = $props()\n</script>\n\n<section\n\tclass=\"mktg-content-width @container relative mx-auto grid w-full items-center justify-items-center gap-10 overflow-hidden px-6 pt-20 pb-0 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14 lg:py-24\"\n>\n\t<div class=\"relative text-center lg:text-left\">\n\t\t<h1 class=\"hero-title mb-6 font-bold text-onSurface\">\n\t\t\tYour local music player,\n\t\t\t<span class=\"inline-block text-primary\">in the browser.</span>\n\t\t</h1>\n\t\t<p\n\t\t\tclass=\"mx-auto mb-7 max-w-2xl text-title-md leading-relaxed text-onSurfaceVariant sm:text-title-lg lg:mx-0 lg:max-w-xl\"\n\t\t>\n\t\t\tPlaylists, queue, equalizer, and playback speed — no uploads or account needed.\n\t\t</p>\n\t\t<div\n\t\t\tclass=\"mx-auto grid max-w-120 grid-cols-1 items-center justify-center gap-4 sm:grid-cols-2 lg:mx-0 lg:justify-start\"\n\t\t>\n\t\t\t<Button\n\t\t\t\tas=\"a\"\n\t\t\t\thref=\"/library/tracks\"\n\t\t\t\tkind=\"filled\"\n\t\t\t\tclass=\"w-full\"\n\t\t\t\tonclick={onOpenPlayerClick}\n\t\t\t>\n\t\t\t\tOpen Player\n\t\t\t</Button>\n\t\t\t<Button as=\"a\" href=\"#how-it-works\" kind=\"outlined\" class=\"w-full\">How it works</Button>\n\t\t</div>\n\t\t<div class=\"mt-4 text-body-md text-onSurfaceVariant\">Free • Works offline • Open source</div>\n\t</div>\n\n\t<div class=\"relative grid gap-4 py-5\">\n\t\t<div class=\"hero-image-shell\">\n\t\t\t<img\n\t\t\t\tclass=\"hero-image block aspect-7/5 h-auto w-full rounded-3xl bg-surfaceContainerHighest object-cover object-bottom-left\"\n\t\t\t\tsrc={heroImg.src}\n\t\t\t\twidth={heroImg.width}\n\t\t\t\theight={heroImg.height}\n\t\t\t\talt=\"Snae Player showing the music library and playback controls\"\n\t\t\t\tloading=\"eager\"\n\t\t\t\tfetchpriority=\"high\"\n\t\t\t/>\n\t\t</div>\n\t\t<div class=\"hero-floating-card hero-floating-card-top lg:absolute lg:max-w-60\">\n\t\t\t<div class=\"text-label-sm text-onSurfaceVariant\">Private by default</div>\n\t\t\t<div class=\"mt-1.5 text-title-sm font-semibold text-onSurface\">\n\t\t\t\tYour files stay on your device\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</section>\n\n<style lang=\"postcss\">\n\t@reference '../../../app.css';\n\n\t.hero-title {\n\t\tfont-size: clamp(3rem, 8cqw, 6.25rem);\n\t\tline-height: 0.92;\n\t\tletter-spacing: -0.05em;\n\t\ttext-wrap: balance;\n\t}\n\n\t.hero-image-shell {\n\t\tposition: relative;\n\t\toverflow: hidden;\n\t\tborder-radius: 2rem;\n\t\tpadding: 0.7rem;\n\t\tbackground:\n\t\t\tlinear-gradient(\n\t\t\t\t160deg,\n\t\t\t\t--alpha(var(--color-surfaceContainerHighest) / 0.95),\n\t\t\t\t--alpha(var(--color-surfaceContainerHigh) / 0.92)\n\t\t\t),\n\t\t\tradial-gradient(circle at top left, --alpha(var(--color-primary) / 0.14), transparent 60%);\n\t\tbox-shadow:\n\t\t\t0 22px 50px --alpha(var(--color-shadow) / 0.18),\n\t\t\tinset 0 0 0 1px --alpha(var(--color-outline) / 0.14);\n\t}\n\n\t.hero-image {\n\t\tbox-shadow: 0 16px 36px --alpha(var(--color-shadow) / 0.12);\n\t}\n\n\t.hero-floating-card {\n\t\tborder: 1px solid --alpha(var(--color-outline) / 0.22);\n\t\tborder-radius: 1.35rem;\n\t\tbackground: --alpha(var(--color-surfaceContainerHighest) / 0.88);\n\t\tpadding: 0.8rem 0.95rem;\n\t\tbox-shadow: 0 14px 28px --alpha(var(--color-shadow) / 0.12);\n\t\tbackdrop-filter: blur(10px);\n\t}\n\n\t@media (width >= --theme(--breakpoint-lg)) {\n\t\t.hero-floating-card-top {\n\t\t\ttop: 0.9rem;\n\t\t\tleft: -1rem;\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(marketing)/components/HowItWorksSection.svelte",
    "content": "<script lang=\"ts\">\n\timport Icon, { type IconType } from '$lib/components/icon/Icon.svelte'\n\timport Section from './Section.svelte'\n\n\tinterface DetailCard {\n\t\ticon: IconType\n\t\ttitle: string\n\t\tdescription: string\n\t}\n\n\tconst detailCards: DetailCard[] = [\n\t\t{\n\t\t\ticon: 'folder',\n\t\t\ttitle: 'You choose the folder',\n\t\t\tdescription:\n\t\t\t\t\"Click Add directory, then pick the music folder you want to use from the browser's built-in folder picker.\",\n\t\t},\n\t\t{\n\t\t\ticon: 'lockCheck',\n\t\t\ttitle: 'Browser permission comes first',\n\t\t\tdescription:\n\t\t\t\t'That permission applies only to the folder you selected, not the rest of your files, and you can revoke it later in site settings.',\n\t\t},\n\t\t{\n\t\t\ticon: 'cached',\n\t\t\ttitle: 'Library data stays on this device',\n\t\t\tdescription:\n\t\t\t\t'Track details, playlists, and app data are saved on this device so the player can reopen quickly and keep working offline.',\n\t\t},\n\t]\n</script>\n\n<Section\n\tid=\"how-it-works\"\n\tclass=\"mktg-content-width\"\n\tlabel=\"How it works\"\n\ttitle=\"Private by design, simple to start\"\n\tdescription=\"Pick a folder, approve access in the browser, and Snae Player reads only the music you choose.\"\n>\n\t<div\n\t\tclass=\"how-it-works-slider grid w-full snap-x snap-mandatory auto-cols-[minmax(18rem,22rem)] grid-flow-col gap-6 overflow-x-auto overscroll-x-contain pb-2 lg:auto-cols-auto lg:grid-flow-row lg:grid-cols-3 lg:overflow-visible lg:pb-0\"\n\t>\n\t\t{#each detailCards as card}\n\t\t\t<article class=\"marketing-scroll-enter-soft how-it-works-card snap-start p-7\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"mb-5 flex size-13 items-center justify-center rounded-2xl bg-secondaryContainer/55 text-secondary\"\n\t\t\t\t>\n\t\t\t\t\t<Icon type={card.icon} />\n\t\t\t\t</div>\n\n\t\t\t\t<h3 class=\"mb-2 text-title-lg font-semibold text-onSurface\">{card.title}</h3>\n\t\t\t\t<p class=\"text-body-lg leading-relaxed text-onSurfaceVariant\">{card.description}</p>\n\t\t\t</article>\n\t\t{/each}\n\t</div>\n</Section>\n\n<style>\n\t@reference '../../../app.css';\n\n\t.how-it-works-slider {\n\t\twidth: 100vw;\n\t\tmargin-inline: calc(50% - 50vw);\n\t\tpadding-inline: --spacing(6);\n\t\tscroll-padding-inline: --spacing(6);\n\t}\n\n\t@media (width >= --theme(--breakpoint-lg)) {\n\t\t.how-it-works-slider {\n\t\t\twidth: 100%;\n\t\t\tmargin-inline: auto;\n\t\t\tpadding-inline: 0;\n\t\t\tscroll-padding-inline: 0;\n\t\t}\n\t}\n\n\t.how-it-works-card {\n\t\tborder: 1px solid --alpha(var(--color-outline) / 0.2);\n\t\tborder-radius: 1.75rem;\n\t\tbackground: linear-gradient(\n\t\t\t155deg,\n\t\t\t--alpha(var(--color-surfaceContainerHighest) / 0.97),\n\t\t\t--alpha(var(--color-surfaceContainerHigh) / 0.9)\n\t\t);\n\t\tbox-shadow: 0 4px 16px --alpha(var(--color-shadow) / 0.06);\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(marketing)/components/Section.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tchildren?: Snippet\n\t\tid?: string\n\t\tclass?: ClassValue\n\t\tlabel: string\n\t\tlabelColor?: 'primary' | 'secondary' | 'tertiary'\n\t\ttitle: string\n\t\tdescription?: string\n\t}\n\n\tconst {\n\t\tchildren,\n\t\tid,\n\t\tclass: className,\n\t\tlabel,\n\t\tlabelColor = 'primary',\n\t\ttitle,\n\t\tdescription,\n\t}: Props = $props()\n\n\tconst labelColorClass = $derived(\n\t\tlabelColor === 'tertiary'\n\t\t\t? 'text-tertiary'\n\t\t\t: labelColor === 'secondary'\n\t\t\t\t? 'text-secondary'\n\t\t\t\t: 'text-primary',\n\t)\n</script>\n\n<section {id} class={[className]}>\n\t<div class={['marketing-scroll-enter mx-auto mb-12 text-center']}>\n\t\t<div class={['mb-3 text-label-lg font-medium tracking-wide', labelColorClass]}>{label}</div>\n\t\t<h2 class=\"text-headline-md font-bold text-onSurface\">{title}</h2>\n\t\t{#if description}\n\t\t\t<p class=\"mt-2 text-title-md text-onSurfaceVariant\">{description}</p>\n\t\t{/if}\n\t</div>\n\n\t{@render children?.()}\n</section>\n\n<style>\n\t:global(.marketing-scroll-enter),\n\t:global(.marketing-scroll-enter-soft) {\n\t\twill-change: opacity, transform;\n\t}\n\n\t@media (prefers-reduced-motion: no-preference) {\n\t\t@supports (animation-timeline: view(block)) {\n\t\t\t:global(.marketing-scroll-enter) {\n\t\t\t\tanimation: marketing-section-enter both;\n\t\t\t\tanimation-timeline: view(block);\n\t\t\t\tanimation-range: entry 8% cover 34%;\n\t\t\t}\n\n\t\t\t:global(.marketing-scroll-enter-soft) {\n\t\t\t\tanimation: marketing-card-enter both;\n\t\t\t\tanimation-timeline: view(block);\n\t\t\t\tanimation-range: entry 6% cover 26%;\n\t\t\t}\n\t\t}\n\t}\n\n\t@keyframes marketing-section-enter {\n\t\tfrom {\n\t\t\topacity: 0;\n\t\t\ttransform: translateY(1.5rem) scale(0.985);\n\t\t}\n\n\t\tto {\n\t\t\topacity: 1;\n\t\t\ttransform: translateY(0) scale(1);\n\t\t}\n\t}\n\n\t@keyframes marketing-card-enter {\n\t\tfrom {\n\t\t\topacity: 0;\n\t\t\ttransform: translateY(1rem) scale(0.985);\n\t\t}\n\n\t\tto {\n\t\t\topacity: 1;\n\t\t\ttransform: translateY(0) scale(1);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/(marketing)/components/SoundControlsSection.svelte",
    "content": "<script lang=\"ts\">\n\timport equalizerPreview from '../assets/marketing-equalizer-preview.avif?as=metadata'\n\timport Section from './Section.svelte'\n</script>\n\n<Section\n\tclass=\"mktg-content-width\"\n\tlabel=\"Sound controls\"\n\tlabelColor=\"tertiary\"\n\ttitle=\"Adjust the sound, set your pace\"\n>\n\t<div class=\"grid gap-4 lg:grid-cols-[minmax(0,0.86fr)_minmax(0,1.24fr)] lg:items-center\">\n\t\t<div class=\"marketing-scroll-enter-soft flex flex-col justify-center gap-8 py-2 max-sm:gap-6\">\n\t\t\t<div class=\"max-w-xl\">\n\t\t\t\t<div class=\"mb-2 text-label-md text-tertiary\">Built-in equalizer</div>\n\t\t\t\t<h3 class=\"text-title-lg font-semibold text-onSurface\">\n\t\t\t\t\tTune the equalizer for your headphones or speakers\n\t\t\t\t</h3>\n\t\t\t\t<p class=\"mt-2 text-body-lg leading-relaxed text-onSurfaceVariant\">\n\t\t\t\t\tStart with a preset, then fine-tune each band until the sound is right.\n\t\t\t\t</p>\n\t\t\t</div>\n\n\t\t\t<div class=\"max-w-xl\">\n\t\t\t\t<div class=\"mb-2 text-label-md text-secondary\">Playback speed</div>\n\t\t\t\t<h3 class=\"text-title-lg font-semibold text-onSurface\">Listen at any speed</h3>\n\t\t\t\t<p class=\"mt-2 text-body-lg leading-relaxed text-onSurfaceVariant\">\n\t\t\t\t\tSlow down for closer listening or move through albums and long mixes at your own pace.\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"equalizer-stage marketing-scroll-enter-soft relative pt-1\">\n\t\t\t<div class=\"equalizer-shot-wrap relative overflow-hidden rounded-[1.9rem]\">\n\t\t\t\t<img\n\t\t\t\t\tsrc={equalizerPreview.src}\n\t\t\t\t\twidth={equalizerPreview.width}\n\t\t\t\t\theight={equalizerPreview.height}\n\t\t\t\t\talt=\"Snae Player equalizer dialog in dark mode with Bass Boost enabled\"\n\t\t\t\t\tclass=\"equalizer-shot absolute top-5 left-[4%] block h-auto max-w-none rounded-[1.4rem] shadow-[0_28px_56px_--alpha(var(--color-shadow)/0.26),0_0_0_1px_--alpha(var(--color-outline)/0.08)]\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</Section>\n\n<style>\n\t@reference '../../../app.css';\n\n\t.equalizer-shot-wrap {\n\t\tmin-height: 18.5rem;\n\t\tbackground:\n\t\t\tradial-gradient(circle at 18% 20%, --alpha(var(--color-tertiary) / 0.2), transparent 36%),\n\t\t\tradial-gradient(circle at 80% 82%, --alpha(var(--color-primary) / 0.12), transparent 34%),\n\t\t\tlinear-gradient(180deg, --alpha(var(--color-surfaceContainerHighest) / 0.18), transparent);\n\t}\n\n\t.equalizer-shot-wrap::before {\n\t\tcontent: '';\n\t\tposition: absolute;\n\t\tinset: auto auto 1rem 1rem;\n\t\twidth: 13rem;\n\t\theight: 13rem;\n\t\tborder-radius: 999px;\n\t\tbackground: --alpha(var(--color-tertiary) / 0.16);\n\t\tfilter: blur(42px);\n\t\tpointer-events: none;\n\t}\n\n\t.equalizer-shot-wrap::after {\n\t\tcontent: '';\n\t\tposition: absolute;\n\t\tinset: 0;\n\t\tborder-radius: inherit;\n\t\tbox-shadow: inset 0 0 0 1px --alpha(var(--color-outline) / 0.08);\n\t\tpointer-events: none;\n\t}\n\n\t.equalizer-shot {\n\t\twidth: 92%;\n\t\ttransform: rotate(-2.5deg);\n\t}\n\n\t@media (width < --theme(--breakpoint-sm)) {\n\t\t.equalizer-shot-wrap {\n\t\t\tmin-height: 15.5rem;\n\t\t}\n\n\t\t.equalizer-shot-wrap::before {\n\t\t\twidth: 8rem;\n\t\t\theight: 8rem;\n\t\t\tinset: auto auto 0.5rem 0.5rem;\n\t\t\tfilter: blur(28px);\n\t\t}\n\n\t\t.equalizer-shot {\n\t\t\ttop: --spacing(3.5);\n\t\t\tleft: 1%;\n\t\t\twidth: 104%;\n\t\t\ttransform: rotate(-2deg);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/routes/+error.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from '$app/state'\n\timport Button from '$lib/components/Button.svelte'\n\n\tconst is404 = $derived(page.status === 404)\n</script>\n\n<div class=\"m-auto flex w-full max-w-65 flex-col items-center p-4 text-center\">\n\t<h1 class=\"text-headline-md\">\n\t\t{#if is404}\n\t\t\t404\n\t\t{:else}\n\t\t\t{page.error?.message}\n\t\t{/if}\n\t</h1>\n\n\t{#if is404}\n\t\t<div class=\"mt-2 text-body-lg\">{m.errorPageDoesNotExist()}</div>\n\t{/if}\n\n\t<Button as=\"a\" href=\"/library/tracks\" class=\"mt-4 w-full\">{m.goHome()}</Button>\n</div>\n"
  },
  {
    "path": "src/routes/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { afterNavigate } from '$app/navigation'\n\timport { page } from '$app/state'\n\timport { MainStore } from '$lib/stores/main/store.svelte.ts'\n\timport { setMainStoreContext } from '$lib/stores/main/use-store.ts'\n\timport { setupAppViewTransitions } from '$lib/view-transitions.svelte.ts'\n\n\tconst { children } = $props()\n\n\tconst mainStore = setMainStoreContext(new MainStore())\n\tsetupAppViewTransitions(() => mainStore.isReducedMotion)\n\n\tafterNavigate((nav) => {\n\t\tlet id = nav.to?.route?.id ?? 'unknown'\n\t\tif (id === 'unknown' && nav.to?.url.pathname === '/') {\n\t\t\tid = '/(marketing)'\n\t\t}\n\n\t\twindow.goatcounter?.count({\n\t\t\tpath: id,\n\t\t})\n\t})\n\n\t$effect(() => {\n\t\tif (page.data.htmlOverflow === 'auto') {\n\t\t\tdocument.documentElement.style.overflowY = 'auto'\n\t\t} else {\n\t\t\tdocument.documentElement.style.overflowY = ''\n\t\t}\n\t})\n</script>\n\n{@render children()}\n"
  },
  {
    "path": "src/routes/+layout.ts",
    "content": "import '../app.css'\nimport { browser } from '$app/environment'\nimport { registerServiceWorker } from '$lib/helpers/register-sw'\nimport { baseLocale, isLocale, overwriteGetLocale, overwriteSetLocale } from '$paraglide/runtime'\n\nexport const ssr = false\nexport const prerender = false\n\nconst initLocale = () => {\n\tconst savedLocale = localStorage.getItem('snae-locale')\n\tconst locale = isLocale(savedLocale) ? savedLocale : baseLocale\n\n\tdocument.documentElement.lang = locale\n\n\treturn locale\n}\n\nif (browser) {\n\tconst locale = initLocale()\n\toverwriteGetLocale(() => locale)\n\toverwriteSetLocale((newLocale) => {\n\t\tlocalStorage.setItem('snae-locale', newLocale)\n\t\twindow.location.reload()\n\t})\n\n\tregisterServiceWorker({\n\t\tonNeedRefresh(update) {\n\t\t\tsnackbar({\n\t\t\t\tid: 'app-update',\n\t\t\t\tmessage: m.appUpdateAvailable(),\n\t\t\t\tduration: false,\n\t\t\t\tcontrols: {\n\t\t\t\t\tlabel: m.reload(),\n\t\t\t\t\taction: update,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "src/server/theme-colors.ts",
    "content": "import type { PaletteToken } from '$lib/theme.ts'\nimport themeCss from '../theme-colors.css?raw'\n\n/** @public */\nexport const THEME_PALLETTE_LIGHT = {} as Record<PaletteToken, string>\n\n/** @public */\nexport const THEME_PALLETTE_DARK = {} as Record<PaletteToken, string>\n\nconst regex =\n\t/--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\n\nfor (const match of themeCss.matchAll(regex)) {\n\tconst colorName = match[1] as PaletteToken\n\tconst lightColor = match[2]\n\tconst darkColor = match[3]\n\n\tinvariant(lightColor && darkColor, `Invalid color definition for ${colorName}`)\n\n\tTHEME_PALLETTE_LIGHT[colorName] = lightColor\n\tTHEME_PALLETTE_DARK[colorName] = darkColor\n}\n"
  },
  {
    "path": "src/service-worker.ts",
    "content": "/// <reference lib='WebWorker' />\n/// <reference types=\"@sveltejs/kit\" />\n/// <reference types=\"../.generated/svelte-kit/ambient.d.ts\" />\n\nimport { PUBLIC_FALLBACK_PAGE } from '$env/static/public'\nimport { build, files, prerendered, version } from '$service-worker'\n\ndeclare const self: ServiceWorkerGlobalScope\n\nconst CACHE = `cache-${version}`\nconst ASSETS = [...build, ...files, ...prerendered, PUBLIC_FALLBACK_PAGE]\n\nself.addEventListener('install', (event) => {\n\t// Create a new cache and add all files to it\n\tasync function addFilesToCache() {\n\t\tconst cache = await caches.open(CACHE)\n\t\tawait cache.addAll(ASSETS)\n\t}\n\n\tevent.waitUntil(addFilesToCache())\n})\n\nself.addEventListener('activate', (event) => {\n\tself.clients.claim()\n\n\t// Remove previous cached data from disk\n\tasync function deleteOldCaches() {\n\t\tfor (const key of await caches.keys()) {\n\t\t\tif (key !== CACHE) {\n\t\t\t\tawait caches.delete(key)\n\t\t\t}\n\t\t}\n\t}\n\n\tevent.waitUntil(deleteOldCaches())\n})\n\nself.addEventListener('fetch', (event) => {\n\tconst { request } = event\n\n\t// ignore POST requests etc\n\tif (request.method !== 'GET') {\n\t\treturn\n\t}\n\n\tconst url = new URL(request.url)\n\n\tif (url.origin !== self.location.origin) {\n\t\treturn\n\t}\n\n\tconst isNavigationRequest = request.mode === 'navigate'\n\n\t// biome-ignore lint/complexity/useSimplifiedLogicExpression: for clarity\n\tif (!ASSETS.includes(url.pathname) && !isNavigationRequest) {\n\t\treturn\n\t}\n\n\tasync function respond() {\n\t\tconst cache = await caches.open(CACHE)\n\n\t\tlet response = await cache.match(url.pathname)\n\t\tif (response) {\n\t\t\treturn response\n\t\t}\n\n\t\ttry {\n\t\t\tresponse = await fetch(request)\n\n\t\t\t// if we're offline, fetch can return a value that is not a Response\n\t\t\t// instead of throwing - and we can't pass this non-Response to respondWith\n\t\t\tif (!(response instanceof Response)) {\n\t\t\t\tthrow new Error('invalid response from fetch')\n\t\t\t}\n\n\t\t\treturn response\n\t\t} catch (error) {\n\t\t\tif (isNavigationRequest) {\n\t\t\t\tconst fallbackResponse = await cache.match(PUBLIC_FALLBACK_PAGE)\n\n\t\t\t\tif (fallbackResponse) {\n\t\t\t\t\tconsole.info('Serving fallback page')\n\n\t\t\t\t\treturn fallbackResponse\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthrow error\n\t\t}\n\t}\n\n\tevent.respondWith(respond())\n})\n\nself.addEventListener('message', (event) => {\n\tif (event.data === 'skip-waiting') {\n\t\tself.skipWaiting()\n\t}\n})\n"
  },
  {
    "path": "src/theme-colors.css",
    "content": "/* This file is auto generated, do not edit manually. */\n@theme {\n\t--color-*: initial;\n\t--color-transparent: transparent;\n\t--color-current: currentColor;\n\t--color-primary: light-dark(#7c5800, #f7bd48);\n\t--color-onPrimary: light-dark(#ffffff, #412d00);\n\t--color-primaryContainer: light-dark(#ffdea7, #5e4200);\n\t--color-onPrimaryContainer: light-dark(#271900, #ffdea7);\n\t--color-secondary: light-dark(#6d5c3f, #dac4a0);\n\t--color-onSecondary: light-dark(#ffffff, #3c2e15);\n\t--color-secondaryContainer: light-dark(#f7dfbb, #54452a);\n\t--color-secondaryContainerVariant: light-dark(#cbb694, #30240c);\n\t--color-onSecondaryContainer: light-dark(#251a04, #f7dfbb);\n\t--color-tertiary: light-dark(#4c6544, #b3cea6);\n\t--color-onTertiary: light-dark(#ffffff, #1f361a);\n\t--color-tertiaryContainer: light-dark(#ceebc1, #354d2e);\n\t--color-onTertiaryContainer: light-dark(#0b2007, #ceebc1);\n\t--color-error: light-dark(#ba1a1a, #ffb4ab);\n\t--color-onError: light-dark(#ffffff, #690005);\n\t--color-errorContainer: light-dark(#ffdad6, #93000a);\n\t--color-onErrorContainer: light-dark(#410002, #ffdad6);\n\t--color-surface: light-dark(#fff8f3, #201b13);\n\t--color-onSurface: light-dark(#201b13, #ece1d4);\n\t--color-surfaceVariant: light-dark(#eee1cf, #4e4639);\n\t--color-onSurfaceVariant: light-dark(#4e4639, #d1c5b4);\n\t--color-surfaceContainerHighest: light-dark(#ece1d4, #3a342b);\n\t--color-surfaceContainerHigh: light-dark(#f1e7d9, #2f2921);\n\t--color-surfaceContainer: light-dark(#f7ecdf, #241f17);\n\t--color-surfaceContainerLow: light-dark(#fdf2e5, #201b13);\n\t--color-surfaceContainerLowest: light-dark(#ffffff, #120e07);\n\t--color-surfaceBright: light-dark(#fff8f3, #3e382f);\n\t--color-surfaceDim: light-dark(#e3d8cc, #17130b);\n\t--color-outline: light-dark(#807667, #9a8f80);\n\t--color-outlineVariant: light-dark(#d1c5b4, #4e4639);\n\t--color-shadow: light-dark(#000000, #000000);\n\t--color-scrim: light-dark(#000000, #000000);\n\t--color-inverseSurface: light-dark(#353027, #ece1d4);\n\t--color-inverseOnSurface: light-dark(#faefe2, #201b13);\n\t--color-inversePrimary: light-dark(#f7bd48, #7c5800);\n}\n"
  },
  {
    "path": "static/supported-browser-check.js",
    "content": "// IMPORTANT. This file must be imported as separate entry point\n// and it cannot use any modern JS syntax.\n\nvar isSupportedBrowser =\n\t'noModule' in HTMLScriptElement.prototype &&\n\tnavigator.locks &&\n\t'timeout' in AbortSignal &&\n\tCSS &&\n\tCSS.supports('color: color-mix(in oklab, black, black)') &&\n\t// Container queries\n\t'container' in document.documentElement.style &&\n\tnavigator.serviceWorker\n\nif (!isSupportedBrowser) {\n\tdocument.getElementById('unsupported-browser').removeAttribute('hidden')\n\tdocument.getElementById('app').style.display = 'none'\n}\n"
  },
  {
    "path": "svelte.config.js",
    "content": "/** @import { Config } from '@sveltejs/kit' */\nimport adapter from '@sveltejs/adapter-static'\nimport { loadEnv } from 'vite'\n\nconst env = loadEnv('production', process.cwd(), 'PUBLIC_')\n\n/** @type {Config} */\nconst config = {\n\tcompilerOptions: {\n\t\trunes: true,\n\t\texperimental: {\n\t\t\tasync: true,\n\t\t},\n\t},\n\tkit: {\n\t\tpaths: {\n\t\t\trelative: false,\n\t\t},\n\t\toutDir: './.generated/svelte-kit',\n\t\tadapter: adapter({\n\t\t\t// When changing this, also update env variable\n\t\t\tfallback: '200.html',\n\t\t}),\n\t\talias: {\n\t\t\t$paraglide: './.generated/paraglide',\n\t\t},\n\t\tcsp: {\n\t\t\tdirectives: {\n\t\t\t\t'default-src': ['none'],\n\t\t\t\t'script-src': ['self', 'https://gc.zgo.at/'],\n\t\t\t\t'style-src': ['self', 'unsafe-inline'],\n\t\t\t\t'img-src': [\n\t\t\t\t\t'self',\n\t\t\t\t\t'blob:',\n\t\t\t\t\tenv.PUBLIC_GOAT_COUNTER_URL ? `${env.PUBLIC_GOAT_COUNTER_URL}/count` : '',\n\t\t\t\t],\n\t\t\t\t'media-src': ['self', 'blob:'],\n\t\t\t\t'font-src': ['self'],\n\t\t\t\t'connect-src': ['self', env.PUBLIC_GOAT_COUNTER_URL ?? ''],\n\t\t\t\t'form-action': ['none'],\n\t\t\t\t'manifest-src': ['self'],\n\t\t\t\t'base-uri': ['none'],\n\t\t\t},\n\t\t},\n\t\ttypescript: {\n\t\t\tconfig: (tsConfig) => {\n\t\t\t\ttsConfig.extends = '../../tsconfig.base.json'\n\t\t\t\ttsConfig.include.push('../paraglide/**/*')\n\n\t\t\t\treturn tsConfig\n\t\t\t},\n\t\t},\n\t\tserviceWorker: {\n\t\t\tregister: false,\n\t\t},\n\t},\n}\n\nexport default config\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n\t\"$schema\": \"https://json.schemastore.org/tsconfig\",\n\t\"compilerOptions\": {\n\t\t\"allowJs\": true,\n\t\t\"moduleDetection\": \"force\",\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"resolveJsonModule\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"sourceMap\": true,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noImplicitReturns\": true,\n\t\t\"noUnusedLocals\": true,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"allowUnreachableCode\": false,\n\t\t\"allowUnusedLabels\": false,\n\t\t\"declaration\": true,\n\t\t\"libReplacement\": false,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"composite\": true,\n\t\t\"emitDeclarationOnly\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"lib\": [\"esnext\", \"DOM\", \"DOM.Iterable\"],\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"module\": \"esnext\"\n\t},\n\t\"include\": [],\n\t\"exclude\": []\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"extends\": \"./.generated/svelte-kit/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"types\": [\"@types/wicg-file-system-access\", \"./.generated/types/auto-imports.d.ts\"]\n\t},\n\t\"references\": [\n\t\t{\n\t\t\t\"path\": \"./tsconfig.node.json\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n\t\"extends\": \"./tsconfig.base.json\",\n\t\"compilerOptions\": {\n\t\t\"types\": [\"@types/node\"]\n\t},\n\t\"files\": [\"vite.config.ts\", \"svelte.config.js\", \"src/lib/theme.ts\"],\n\t\"include\": [\"lib/**/*\", \"scripts/**/*\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { paraglideVitePlugin } from '@inlang/paraglide-js'\nimport { sveltekit } from '@sveltejs/kit/vite'\nimport tailwindcss from '@tailwindcss/vite'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport { defineConfig } from 'vite'\nimport { imageMetadataPlugin } from './lib/vite-image-metadata.ts'\nimport { logChunkSizePlugin } from './lib/vite-log-chunk-size.ts'\n\nconst getAutoImportPlugin = (dts: string | false = false) =>\n\tAutoImport({\n\t\tdts,\n\t\timports: [\n\t\t\t{\n\t\t\t\t'$paraglide/messages': [['*', 'm']],\n\t\t\t\t'$lib/stores/player/use-store.ts': ['usePlayer'],\n\t\t\t\t'$lib/stores/main/use-store.ts': ['useMainStore'],\n\t\t\t\t'$lib/stores/dialogs/use-store.ts': ['useDialogsStore'],\n\t\t\t\t'$lib/components/menu/MenuRenderer.svelte': ['useMenu'],\n\t\t\t\t'$lib/components/snackbar/snackbar.ts': ['snackbar'],\n\t\t\t\t'tiny-invariant': [['default', 'invariant']],\n\t\t\t\tsvelte: ['untrack'],\n\t\t\t},\n\t\t],\n\t})\n\nexport default defineConfig({\n\tserver: {\n\t\tfs: {\n\t\t\tallow: ['./.generated'],\n\t\t},\n\t\twarmup: {\n\t\t\t// Avoids page reloading in Dev mode. When vite supports bundled-dev mode this can be removed.\n\t\t\tclientFiles: [\n\t\t\t\t'src/lib/components/**/*.svelte',\n\t\t\t\t'src/lib/library/scan-actions/scanner/worker.ts',\n\t\t\t],\n\t\t},\n\t},\n\t// Tell Vitest to use the `browser` entry points in `package.json` files, even though it's running in Node\n\tresolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,\n\tbuild: {\n\t\ttarget: ['chrome130', 'safari18'],\n\t\trolldownOptions: {\n\t\t\toutput: {\n\t\t\t\tcomments: false,\n\t\t\t\tadvancedChunks: {\n\t\t\t\t\tgroups: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// Merge all css into a single file\n\t\t\t\t\t\t\tname: 'styles',\n\t\t\t\t\t\t\ttest: /\\.css$/,\n\t\t\t\t\t\t\tminModuleSize: 0,\n\t\t\t\t\t\t\tpriority: 100,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// Merge smaller chunks than together\n\t\t\t\t\t\t\tname: 'small-chunks',\n\t\t\t\t\t\t\tmaxModuleSize: 1 * 1024,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n\tworker: {\n\t\tformat: 'es',\n\t\tplugins: () => [getAutoImportPlugin()],\n\t},\n\tplugins: [\n\t\timageMetadataPlugin(),\n\t\ttailwindcss(),\n\t\tsveltekit(),\n\t\tparaglideVitePlugin({\n\t\t\tproject: './project.inlang',\n\t\t\toutdir: './.generated/paraglide',\n\t\t\tstrategy: ['baseLocale'],\n\t\t\tisServer: 'import.meta.env.SSR',\n\t\t}),\n\t\tgetAutoImportPlugin('./.generated/types/auto-imports.d.ts'),\n\t\tlogChunkSizePlugin(),\n\t\t{\n\t\t\tname: 'ssr-config',\n\t\t\tconfig(config) {\n\t\t\t\tconst isSsr = config?.build?.ssr\n\n\t\t\t\t// Since this is mostly SPA, server logs are mostly noise.\n\t\t\t\tconfig.logLevel = isSsr ? 'warn' : 'info'\n\n\t\t\t\treturn config\n\t\t\t},\n\t\t},\n\t],\n})\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defaultExclude, defineConfig, mergeConfig } from 'vitest/config'\nimport viteConfig from './vite.config.ts'\n\nexport default mergeConfig(\n\tviteConfig,\n\tdefineConfig({\n\t\ttest: {\n\t\t\tenvironment: 'happy-dom',\n\t\t\texclude: [...defaultExclude, '.generated/**', 'build/**'],\n\t\t},\n\t}),\n)\n"
  }
]