Full Code of minht11/local-music-pwa for AI

main 23712cbccf40 cached
230 files
547.6 KB
167.9k tokens
297 symbols
1 requests
Download .txt
Showing preview only (612K chars total). Download the full file or copy to clipboard to get everything.
Repository: minht11/local-music-pwa
Branch: main
Commit: 23712cbccf40
Files: 230
Total size: 547.6 KB

Directory structure:
gitextract_z80t98b9/

├── .env.example
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── AGENTS.md
├── LICENSE.txt
├── README.md
├── biome.jsonc
├── knip.json
├── lib/
│   ├── vite-image-metadata.ts
│   └── vite-log-chunk-size.ts
├── messages/
│   ├── de.json
│   ├── en.json
│   ├── fr.json
│   ├── lt.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── netlify.toml
├── package.json
├── patches/
│   └── @material__material-color-utilities.patch
├── pnpm-workspace.yaml
├── project.inlang/
│   └── settings.json
├── scripts/
│   ├── check-translations.ts
│   └── gen-color-theme.ts
├── src/
│   ├── ambient.d.ts
│   ├── app.css
│   ├── app.d.ts
│   ├── app.html
│   ├── hooks.server.ts
│   ├── lib/
│   │   ├── app-metadata.ts
│   │   ├── attachments/
│   │   │   ├── ripple.ts
│   │   │   └── tooltip.ts
│   │   ├── components/
│   │   │   ├── AlbumsListContainer.svelte
│   │   │   ├── ArtistListContainer.svelte
│   │   │   ├── Artwork.svelte
│   │   │   ├── BackButton.svelte
│   │   │   ├── Button.svelte
│   │   │   ├── FavoriteButton.svelte
│   │   │   ├── Header.svelte
│   │   │   ├── IconButton.svelte
│   │   │   ├── ListDetailsLayout.svelte
│   │   │   ├── ListItem.svelte
│   │   │   ├── MenuButton.svelte
│   │   │   ├── PlayerOverlay.svelte
│   │   │   ├── ScrollContainer.svelte
│   │   │   ├── Select.svelte
│   │   │   ├── Separator.svelte
│   │   │   ├── Slider.svelte
│   │   │   ├── Spinner.svelte
│   │   │   ├── Switch.svelte
│   │   │   ├── Tabs.svelte
│   │   │   ├── TextField.svelte
│   │   │   ├── VirtualContainer.svelte
│   │   │   ├── WrapTranslation.svelte
│   │   │   ├── animated-icons/
│   │   │   │   ├── PlayPauseIcon.svelte
│   │   │   │   └── PlayPreviousNextIcon.svelte
│   │   │   ├── dialog/
│   │   │   │   ├── CommonDialog.svelte
│   │   │   │   ├── Dialog.svelte
│   │   │   │   └── DialogFooter.svelte
│   │   │   ├── global-dialogs/
│   │   │   │   ├── EqualizerDialog.svelte
│   │   │   │   ├── RemoveFromLibraryDialog.svelte
│   │   │   │   ├── dialogs.ts
│   │   │   │   └── playlists/
│   │   │   │       ├── AddToPlaylistDialog.svelte
│   │   │   │       ├── AddToPlaylistDialogContent.svelte
│   │   │   │       ├── EditPlaylistDialog.svelte
│   │   │   │       └── NewPlaylistDialog.svelte
│   │   │   ├── icon/
│   │   │   │   ├── Icon.svelte
│   │   │   │   └── icon-paths.server.ts
│   │   │   ├── library-grid/
│   │   │   │   ├── LibraryGridItem.svelte
│   │   │   │   └── LibraryGridListContainer.svelte
│   │   │   ├── menu/
│   │   │   │   ├── Menu.svelte
│   │   │   │   ├── MenuRenderer.svelte
│   │   │   │   ├── positioning.ts
│   │   │   │   └── types.ts
│   │   │   ├── player/
│   │   │   │   ├── MainControls.svelte
│   │   │   │   ├── PlayerArtwork.svelte
│   │   │   │   ├── Timeline.svelte
│   │   │   │   ├── VolumeSlider.svelte
│   │   │   │   └── buttons/
│   │   │   │       ├── ActiveIndicator.svelte
│   │   │   │       ├── PlayNextButton.svelte
│   │   │   │       ├── PlayPrevButton.svelte
│   │   │   │       ├── PlayToggleButton.svelte
│   │   │   │       ├── PlayTogglePillButton.svelte
│   │   │   │       ├── PlayerFavoriteButton.svelte
│   │   │   │       ├── RepeatButton.svelte
│   │   │   │       └── ShuffleButton.svelte
│   │   │   ├── playlists/
│   │   │   │   ├── PlaylistListContainer.svelte
│   │   │   │   └── PlaylistListItem.svelte
│   │   │   ├── snackbar/
│   │   │   │   ├── Snackbar.svelte
│   │   │   │   ├── SnackbarRenderer.svelte
│   │   │   │   ├── snackbar.ts
│   │   │   │   └── store.svelte.ts
│   │   │   └── tracks/
│   │   │       ├── TrackListItem.svelte
│   │   │       ├── TracksListContainer.svelte
│   │   │       ├── selection.svelte.ts
│   │   │       ├── use-track-drag-controller.svelte.ts
│   │   │       ├── use-track-menu-items.ts
│   │   │       └── use-track-selection-controller.svelte.ts
│   │   ├── db/
│   │   │   ├── database.ts
│   │   │   ├── events.ts
│   │   │   ├── lock-database.ts
│   │   │   └── query/
│   │   │       ├── base-query.svelte.ts
│   │   │       ├── inline-query.svelte.ts
│   │   │       ├── page-query.svelte.ts
│   │   │       └── query.ts
│   │   ├── helpers/
│   │   │   ├── __tests__/
│   │   │   │   └── serial-queue.test.ts
│   │   │   ├── animations.ts
│   │   │   ├── audio.ts
│   │   │   ├── create-managed-artwork.svelte.ts
│   │   │   ├── debounced.svelte.ts
│   │   │   ├── file-system.ts
│   │   │   ├── focus.ts
│   │   │   ├── input.ts
│   │   │   ├── persist.svelte.ts
│   │   │   ├── register-sw.ts
│   │   │   ├── serial-queue.ts
│   │   │   ├── test-helpers.ts
│   │   │   ├── ui-action.ts
│   │   │   ├── utils/
│   │   │   │   ├── array.ts
│   │   │   │   ├── assign.ts
│   │   │   │   ├── clamp.ts
│   │   │   │   ├── debounce.ts
│   │   │   │   ├── format-duration.ts
│   │   │   │   ├── integers.ts
│   │   │   │   ├── navigate.ts
│   │   │   │   ├── text.ts
│   │   │   │   ├── throttle.ts
│   │   │   │   ├── ua.ts
│   │   │   │   └── wait.ts
│   │   │   └── virtualizer.svelte.ts
│   │   ├── layout-bottom-bar.svelte.ts
│   │   ├── library/
│   │   │   ├── __tests__/
│   │   │   │   ├── play-history.test.ts
│   │   │   │   ├── playlists.test.ts
│   │   │   │   └── remove.test.ts
│   │   │   ├── get/
│   │   │   │   ├── __tests__/
│   │   │   │   │   └── value.test.ts
│   │   │   │   ├── ids-queries.ts
│   │   │   │   ├── ids.ts
│   │   │   │   ├── value-queries.ts
│   │   │   │   └── value.ts
│   │   │   ├── play-history-actions.ts
│   │   │   ├── playlists-actions.ts
│   │   │   ├── remove.ts
│   │   │   ├── scan-actions/
│   │   │   │   ├── directories.ts
│   │   │   │   ├── scan-tracks.ts
│   │   │   │   └── scanner/
│   │   │   │       ├── actions.ts
│   │   │   │       ├── import-track.ts
│   │   │   │       ├── parse/
│   │   │   │       │   ├── format-artwork.ts
│   │   │   │       │   ├── image-primary-color.ts
│   │   │   │       │   └── parse-track.ts
│   │   │   │       ├── start.ts
│   │   │   │       ├── types.ts
│   │   │   │       └── worker.ts
│   │   │   ├── tracks-queries.ts
│   │   │   └── types.ts
│   │   ├── menu-actions/
│   │   │   └── playlists.ts
│   │   ├── stores/
│   │   │   ├── dialogs/
│   │   │   │   ├── store.svelte.ts
│   │   │   │   └── use-store.ts
│   │   │   ├── main/
│   │   │   │   ├── store.svelte.ts
│   │   │   │   └── use-store.ts
│   │   │   └── player/
│   │   │       ├── __test__/
│   │   │       │   ├── audio-loader.test.ts
│   │   │       │   ├── equalizer.test.ts
│   │   │       │   ├── player.svelte.test.ts
│   │   │       │   └── queue.test.ts
│   │   │       ├── audio-loader.svelte.ts
│   │   │       ├── equalizer.svelte.ts
│   │   │       ├── player.svelte.ts
│   │   │       ├── queue.svelte.ts
│   │   │       └── use-store.ts
│   │   ├── theme.ts
│   │   └── view-transitions.svelte.ts
│   ├── params/
│   │   └── libraryEntities.ts
│   ├── routes/
│   │   ├── (app)/
│   │   │   ├── (plain)/
│   │   │   │   ├── +layout.svelte
│   │   │   │   ├── about/
│   │   │   │   │   ├── +page.svelte
│   │   │   │   │   └── +page.ts
│   │   │   │   └── settings/
│   │   │   │       ├── +page.svelte
│   │   │   │       ├── +page.ts
│   │   │   │       └── components/
│   │   │   │           ├── DirectoriesList.svelte
│   │   │   │           ├── InstallAppBanner.svelte
│   │   │   │           └── MissingFsApiBanner.svelte
│   │   │   ├── +layout.svelte
│   │   │   ├── layout/
│   │   │   │   ├── app-install-prompt.ts
│   │   │   │   ├── setup-directories-permission-prompt.svelte.ts
│   │   │   │   └── setup-theme.svelte.ts
│   │   │   ├── library/
│   │   │   │   └── [[slug=libraryEntities]]/
│   │   │   │       ├── +layout.svelte
│   │   │   │       ├── +layout.ts
│   │   │   │       ├── +page.svelte
│   │   │   │       ├── Search.svelte
│   │   │   │       ├── [uuid]/
│   │   │   │       │   ├── +page.svelte
│   │   │   │       │   └── +page.ts
│   │   │   │       ├── config.ts
│   │   │   │       └── store.svelte.ts
│   │   │   └── player/
│   │   │       ├── +layout.svelte
│   │   │       ├── +layout.ts
│   │   │       ├── +page.ts
│   │   │       ├── history/
│   │   │       │   └── +page.ts
│   │   │       ├── layout-props.ts
│   │   │       └── queue/
│   │   │           └── +page.ts
│   │   ├── (assets)/
│   │   │   ├── icons/
│   │   │   │   └── icon.server.ts
│   │   │   └── manifest.webmanifest/
│   │   │       └── +server.ts
│   │   ├── (marketing)/
│   │   │   ├── +page.svelte
│   │   │   ├── +page.ts
│   │   │   ├── AGENTS.md
│   │   │   ├── TONE_OF_VOICE.md
│   │   │   ├── assets/
│   │   │   │   ├── hero.avif
│   │   │   │   └── marketing-equalizer-preview.avif
│   │   │   └── components/
│   │   │       ├── FeaturesSection.svelte
│   │   │       ├── GettingStartedSection.svelte
│   │   │       ├── HeroSection.svelte
│   │   │       ├── HowItWorksSection.svelte
│   │   │       ├── Section.svelte
│   │   │       └── SoundControlsSection.svelte
│   │   ├── +error.svelte
│   │   ├── +layout.svelte
│   │   └── +layout.ts
│   ├── server/
│   │   └── theme-colors.ts
│   ├── service-worker.ts
│   └── theme-colors.css
├── static/
│   └── supported-browser-check.js
├── svelte.config.js
├── tsconfig.base.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── vitest.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .env.example
================================================
PUBLIC_FALLBACK_PAGE=/200.html
PUBLIC_GOAT_COUNTER_URL=

================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---

## ⚠️ Before creating this issue

**Please check if a similar issue already exists:**
- [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this bug has not been reported before

## 🐛 Bug Description

A clear and concise description of what the bug is.

## 🔄 Steps to Reproduce

1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error

## ✅ Expected Behavior

A clear and concise description of what you expected to happen.

## ❌ Actual Behavior

A clear and concise description of what actually happened.

## 📱 Device Information

**Device Type:** (e.g., Desktop, Mobile, Tablet)
**Operating System:** (e.g., Windows 11, macOS 14, iOS 17, Android 13)
**Browser:** (e.g., Chrome 120, Firefox 121, Safari 17)
**Browser Version:** 
**Screen Resolution:** (if relevant)

## 📸 Screenshots/Videos

If applicable, add screenshots or videos to help explain your problem.

<!-- You can drag and drop images/videos directly into this text area -->

## 🎵 Music Library Details (if relevant)

**Library Size:** (approximate number of songs/albums)
**File Formats:** (e.g., MP3, FLAC, AAC)

## 🔧 Additional Context

Add any other context about the problem here. Include:
- Console errors (if any)
- Network connectivity issues
- Any recent changes to your music library
- Whether this happens consistently or intermittently

## 📋 Browser Console Errors

If there are any console errors, please include them here:

```
Paste console errors here
```

## 🔍 Additional Information

- Does this issue occur in incognito/private browsing mode?
- Have you tried clearing browser cache/data?
- Does this issue occur on other devices/browsers?


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
  - name: ❓ Questions & Support
    url: https://github.com/minht11/local-music-pwa/discussions
    about: Ask questions, get help, or discuss how to use the app
  - name: 💬 General Discussions
    url: https://github.com/minht11/local-music-pwa/discussions
    about: Share ideas, feedback, or chat with the community


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---

## ⚠️ Before creating this issue

**Please check if a similar feature has already been requested:**
- [ ] I have searched the [existing issues](https://github.com/minht11/local-music-pwa/issues) and this feature has not been requested before

## 💡 What feature would you like to see?

A clear description of the feature you'd like to see implemented.

## 🎯 Why do you need this feature?

What problem would this solve or what would this help you do?

## 🎨 Screenshots/Examples (Optional)

If you have examples from other apps or mockups, add them here.

<!-- You can drag and drop images directly into this text area -->

## 📋 Additional Context

Any other details that might be helpful.


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI

on:
  pull_request:
  push:
    branches:
      - main

env:
  PUBLIC_FALLBACK_PAGE: ${{ vars.PUBLIC_FALLBACK_PAGE }}
  PUBLIC_GOAT_COUNTER_URL: ${{ vars.PUBLIC_GOAT_COUNTER_URL }}

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
      - uses: pnpm/action-setup@v6.0.4
        with:
          run_install: false
      - uses: actions/setup-node@v6.4.0
        with:
          node-version-file: "package.json"
          cache: "pnpm"
      - run: pnpm install
      - name: Build generated files
        run: pnpm run build
      - run: pnpm run biome-check
      - run: pnpm run prettier-check
      - run: pnpm run type-check
      - run: pnpm test
      - run: pnpm run knip


================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
build
.generated
.env
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
coverage

================================================
FILE: .prettierignore
================================================
.DS_Store
node_modules
/build/
/.generated/
/package
.env
.env.*
!.env.example

# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

# Let Biome handle these files
*.ts
*.tsx
*.js
*.json
*.jsonc
*.css
*.html

================================================
FILE: .prettierrc
================================================
{
	"useTabs": true,
	"singleQuote": true,
	"trailingComma": "all",
	"semi": false,
	"printWidth": 100,
	"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
	"tailwindStylesheet": "./src/app.css",
	"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}


================================================
FILE: .vscode/extensions.json
================================================
{
	"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome", "svelte.svelte-vscode"]
}


================================================
FILE: .vscode/settings.json
================================================
{
	"svelte.plugin.svelte.compilerWarnings": {
		"missing-declaration": "ignore"
	},
	"editor.codeActionsOnSave": {
		"source.organizeImports.biome": "explicit",
		"source.fixAll.biome": "explicit"
	},
	"[jsonc]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"[json]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"[javascript]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"[typescript]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"[css]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"[html]": {
		"editor.defaultFormatter": "biomejs.biome"
	},
	"editor.formatOnSave": true,
	"eslint.useESLintClass": true,
	"npm.packageManager": "pnpm",
	"js/ts.tsdk.path": "node_modules/typescript/lib",
	"files.associations": {
		"*.css": "tailwindcss"
	},
	"css.lint.unknownAtRules": "ignore"
}


================================================
FILE: AGENTS.md
================================================
# Agent instructions

## Project Overview

**Snae Player** is a privacy-first local music PWA that runs entirely in the browser. Built with **SvelteKit 5**, **TypeScript**, and **Tailwind CSS 4**, it emphasizes performance, type safety, and maintainability.

### Core Features

- Local music playback using File System Access API or Files API fallback
- Privacy-preserving (no data sent to servers)
- IndexedDB for local storage and metadata
- Web Workers for performance-intensive operations
- Progressive Web App with offline support

## Technology Stack

### Frontend Framework

- **SvelteKit 5** with Svelte Runes (`$state`, `$derived`, `$effect`)
- **TypeScript** strict mode with no `any` types
- **Tailwind CSS 4** with custom design system in `src/app.css`
- **Vite 8** with Rolldown bundler

### Core Dependencies

```json
{
	"dependencies": {
		"@material/material-color-utilities": "^0.4.0",
		"@tanstack/virtual-core": "^3.13.23",
		"idb": "^8.0.3",
		"music-metadata": "^11.12.3",
		"tiny-invariant": "^1.3.3",
		"weak-lru-cache": "^1.2.2"
	}
}
```

### Development Tools

- **pnpm** for package management
- **Biome** for linting (primary)
- **Prettier** for Svelte formatting
- **Vitest** for testing with `fake-indexeddb`
- **unplugin-auto-import** for global utilities
- **@inlang/paraglide-js** for i18n (compiled to `.generated/paraglide/`)

## File Organization

```
src/
├── routes/
│   ├── (app)/                     # Main application routes (with bottom bar)
│   │   ├── library/               # Music library with slug-based entity views
│   │   ├── player/                # Full-screen audio player (queue, history)
│   │   ├── layout/                # Layout-level setup (install prompt, theme)
│   │   └── (plain)/               # Routes without bottom nav bar
│   │       ├── settings/          # App settings
│   │       └── about/             # About page
│   ├── (marketing)/               # Landing page
│   └── (assets)/                  # Dynamic asset routes
├── lib/
│   ├── components/                # Reusable UI components
│   │   ├── icon/                  # SVG icon system
│   │   ├── tracks/                # Track list components
│   │   ├── playlists/             # Playlist components
│   │   ├── player/                # Player UI components
│   │   ├── menu/                  # Context menu system
│   │   ├── dialog/                # Modal dialogs
│   │   ├── snackbar/              # Toast notifications
│   │   ├── app-dialogs/           # App-level dialogs
│   │   ├── library-grid/          # Library grid layout
│   │   └── animated-icons/        # Animated icon components
│   ├── stores/                    # Global state management
│   │   ├── main/                  # App settings, theme (MainStore)
│   │   ├── player/                # Audio playback state (PlayerStore)
│   │   └── dialogs/               # Dialog state (DialogsStore)
│   ├── db/                        # IndexedDB operations
│   │   ├── query/                 # Reactive database queries
│   │   ├── database.ts            # DB schema & connection
│   │   └── events.ts              # DB change events
│   ├── library/                   # Music library operations
│   │   ├── scan-actions/          # File scanning and parsing
│   │   ├── get/                   # Query helpers (ids, values)
│   │   ├── playlists-actions.ts
│   │   ├── play-history-actions.ts
│   │   ├── tracks-queries.ts
│   │   └── types.ts
│   ├── helpers/                   # Utility functions
│   └── attachments/               # Svelte element attachments (ripple, tooltip)
tests/
├── lib/
│   └── library/                   # Library functionality tests
└── shared.ts                      # Test utilities (clearDatabaseStores)
```

## Design System & Styling

### Design Tokens

Use design tokens from `src/app.css` and `src/theme-colors.css` — **never arbitrary values**.

Prefer theme breakpoint variables in media queries, for example `@media (width >= --theme(--breakpoint-sm))`.
Use a custom breakpoint only when there is no matching theme breakpoint for the behavior you need.
In Svelte component `<style>` blocks, add the appropriate `@reference` when using theme tokens such as `--theme(...)`.

> **Critical**: Color token names use **camelCase**, not kebab-case.

```css
/* Colors - semantic, theme-aware (camelCase!) */
color: var(--color-primary)
color: var(--color-onSurface)              /* NOT --color-on-surface */
color: var(--color-onSurfaceVariant)
background: var(--color-surfaceContainer)  /* NOT --color-surface-container */
background: var(--color-surfaceContainerHigh)
background: var(--color-primaryContainer)
color: var(--color-onPrimaryContainer)

/* Spacing - use Tailwind spacing scale */
margin: --spacing(4)
padding: --spacing(8)
gap: --spacing(2)

/* Typography - use utility classes, not font-size directly */
/* Apply as CSS class: class="text-body-md" */
```

### Required Patterns

```html
<!-- All clickable elements need .interactable -->
<button class="interactable">Click me</button>

<!-- Container styling -->
<div class="card">Content</div>

<!-- Touch feedback (from $lib/attachments/ripple.ts) -->
<button {@attach ripple()}>Interactive</button>

<!-- Tooltips (from $lib/attachments/tooltip.ts) -->
<button {@attach tooltip('Help text')}>?</button>
```

### Typography Scale

Available as CSS utility classes (defined in `src/app.css`):

- `text-headline-lg` / `text-headline-md` / `text-headline-sm` - Headings
- `text-title-lg` / `text-title-md` / `text-title-sm` - Component titles
- `text-body-lg` / `text-body-md` / `text-body-sm` - Body text (default: `text-body-md`)
- `text-label-lg` / `text-label-md` / `text-label-sm` - Labels

## Auto-Imported Utilities

These are globally available without imports (configured in `vite.config.ts`). **Never import them manually.**

```typescript
// Internationalization (from @inlang/paraglide-js)
m.tracks()          // m.albums(), m.settings(), etc.

// Stores (context-based, call inside Svelte component tree)
usePlayer()         // Audio player state (PlayerStore)
useMainStore()      // App settings, theme (MainStore)
useDialogsStore()   // Dialog state (DialogsStore)
useMenu()           // Context menus (MenuAPI)

// Notifications
snackbar('Message text')              // Show toast
snackbar({ id: 'x', message: '...' }) // With options
snackbar.unexpectedError(error)        // Error toast
snackbar.dismiss('id')                 // Dismiss

// Utilities
invariant(condition, 'message')  // Runtime assertions (tiny-invariant)
untrack(() => value)             // Svelte untrack
```

Note: `Snippet<T>` and `ClassValue` are **Svelte/TypeScript built-in types**, not auto-imports.

## Component Development

### Svelte 5 Component Pattern

```svelte
<script lang="ts">
	interface Props {
		items: string[]
		selectedId?: number
		onSelect?: (id: number) => void
		children?: Snippet<[string, number]>
	}

	const { items, selectedId = 0, onSelect, children }: Props = $props()

	let internalState = $state(selectedId)
	const filteredItems = $derived(items.filter(Boolean))

	$effect(() => {
		// React to prop changes
		internalState = selectedId
	})
</script>

<div class="card">
	{#each filteredItems as item, i}
		<button
			class="interactable"
			class:selected={i === internalState}
			onclick={() => {
				internalState = i
				onSelect?.(i)
			}}
			{@attach ripple()}
		>
			{#if children}
				{@render children(item, i)}
			{:else}
				{item}
			{/if}
		</button>
	{/each}
</div>

<style>
	.selected {
		background: var(--color-primaryContainer);
		color: var(--color-onPrimaryContainer);
	}
</style>
```

### Key Component Library

Available in `src/lib/components/`:

**Basic UI:**

- `Button.svelte` - Primary/secondary buttons
- `IconButton.svelte` - Icon-only buttons
- `MenuButton.svelte` - Button that opens a context menu
- `Icon.svelte` - SVG icon system
- `TextField.svelte` - Text input fields
- `Select.svelte` - Dropdown selects
- `Switch.svelte` - Toggle switches
- `Slider.svelte` - Range slider
- `Tabs.svelte` - Tab navigation
- `Spinner.svelte` - Loading indicator
- `FavoriteButton.svelte` - Toggle favorite state

**Layout:**

- `Header.svelte` - Page headers
- `BackButton.svelte` - Navigation back button
- `Separator.svelte` - Visual dividers
- `ScrollContainer.svelte` - Scrollable container
- `VirtualContainer.svelte` - Virtual scrolling for large lists
- `ListDetailsLayout.svelte` - Master-detail layout
- `ListItem.svelte` - Generic list item

**Music-specific:**

- `Artwork.svelte` - Album/track artwork
- `PlayerOverlay.svelte` - Mini player overlay
- `TracksListContainer.svelte` - Virtual track lists (`src/lib/components/tracks/`)
- `PlaylistListContainer.svelte` - Playlist list (`src/lib/components/playlists/`)
- `AlbumsListContainer.svelte` - Albums grid/list
- `ArtistListContainer.svelte` - Artists list

## State Management

### Store Architecture

Uses context-based stores with Svelte 5 runes:

```typescript
// Main application store
const mainStore = useMainStore()
mainStore.theme                  // AppThemeOption: 'light' | 'dark' | 'auto'
mainStore.isThemeDark            // boolean (derived)
mainStore.motion                 // AppMotionOption: 'normal' | 'reduced' | 'auto'
mainStore.isReducedMotion        // boolean (derived)
mainStore.pickColorFromArtwork   // boolean
mainStore.volumeSliderEnabled    // boolean
mainStore.librarySplitLayoutEnabled // boolean

// Audio player store
const player = usePlayer()
player.playing          // boolean (true = playing)
player.loading          // boolean (true = loading audio)
player.activeTrack      // TrackData | undefined
player.itemsIds         // readonly number[] (queue track IDs)
player.currentTime      // number (seconds)
player.duration         // number (seconds)
player.volume           // number (0–100)
player.muted            // boolean
player.shuffle          // boolean
player.repeat           // PlayerRepeat: 'none' | 'one' | 'all'
player.equalizer        // EqualizerStore
player.artworkSrc       // string | undefined

// Player actions
player.playTrack(trackIds, options?)  // Set queue and play
player.togglePlay(force?)             // Toggle or force play/pause
player.playNext()                     // Next track
player.playPrev()                     // Previous track
player.seek(time)                     // Seek to time in seconds
player.toggleRepeat()                 // Cycle repeat mode
player.toggleShuffle()                // Toggle shuffle
player.addToQueue(trackId)            // Add track(s) to queue
player.removeFromQueue(index)         // Remove by queue index
player.clearQueue()                   // Empty the queue
```

### Persistence

Stores self-persist via the `persist()` helper (used internally in store constructors — do not call it for new ad-hoc values):

```typescript
// Inside a store class constructor
persist('storeName', this, ['fieldA', 'fieldB'])
// Keys are persisted to localStorage under snaeplayer-{storeName}.{key}
```

## Database Layer

### Architecture

- **IndexedDB** via `idb` library
- **Reactive queries** that auto-update UI components
- **Type-safe** operations
- **Migration system** for schema changes

### Database Schema

```typescript
// From $lib/library/types.ts
interface Track {
	id: number
	uuid: string
	name: string
	artists: StringOrUnknownItem[]
	album: StringOrUnknownItem
	year: StringOrUnknownItem
	duration: number
	genre: string[]
	trackNo: number
	trackOf: number
	discNo: number
	discOf: number
	fileName: string
	directory: number   // FK to Directory.id; -1 = legacy no-native-directory
	scannedAt: number
	file: FileEntity
	image?: { optimized: boolean; small: Blob; full: Blob }
	primaryColor?: number
}

interface Album {
	id: number
	uuid: string
	name: string
	artists: string[]
	year?: string
	image?: Blob
}

interface Artist {
	id: number
	uuid: string
	name: string
}

interface Playlist {
	id: number
	uuid: string
	name: string
	description: string
	createdAt: number
}

interface PlaylistEntry {
	id: number
	playlistId: number
	trackId: number
	addedAt: number
}

interface PlayHistoryEntry {
	id: number
	trackId: number
	playedAt: number
}

interface Directory {
	id: number
	handle: FileSystemDirectoryHandle
}
```

Stores: `tracks`, `albums`, `artists`, `playlists`, `playlistEntries`, `directories`, `playHistory`

Special constants from `$lib/library/types.ts`:

- `FAVORITE_PLAYLIST_ID = -1` — built-in favorites playlist (not user-modifiable)
- `UNKNOWN_ITEM = '~\0unknown'` — sentinel for unknown artist/album/year
- `LEGACY_NO_NATIVE_DIRECTORY = -1` — for tracks without a directory handle

### Database Operations

```typescript
// Basic operations
import { getDatabase } from '$lib/db/database.ts'

const db = await getDatabase()
const tracks = await db.getAll('tracks')
const track = await db.get('tracks', trackId)

// Reactive queries
import { createPageQuery } from '$lib/db/query/page-query.svelte.ts'

const tracksQuery = createPageQuery({
	queryFn: async () => {
		const db = await getDatabase()
		return await db.getAll('tracks')
	},
	onDatabaseChange: (changes) => {
		// Auto-refetch when tracks change
		return changes.some((c) => c.storeName === 'tracks')
	},
})
```

## Music Library Operations

### File Scanning

```typescript
// Scanner architecture
import { scanTracks } from '$lib/library/scan-actions/scan-tracks.ts'

// Scan new files
await scanTracks({
	action: 'scan-new-directory',
	files: fileEntities,
})
```

### Playlist Management

```typescript
import {
	dbCreatePlaylist,
	dbAddTracksToPlaylist,
	dbRemoveTracksFromPlaylist,
	toggleFavoriteTrack,
} from '$lib/library/playlists-actions.ts'

// Create playlist
const playlistId = await dbCreatePlaylist('My Playlist', 'Description')

// Add tracks
await dbAddTracksToPlaylist(playlistId, trackIds)

// Favorites
await toggleFavoriteTrack(trackId) // Adds/removes from favorites
```

## Testing Guidelines

### Test Structure

```typescript
import { describe, it, expect, vi, afterEach } from 'vitest'
import { clearDatabaseStores } from '../../shared.ts'

describe('component functionality', () => {
	afterEach(async () => {
		await clearDatabaseStores()
		vi.clearAllMocks()
	})

	it('should handle user interaction correctly', async () => {
		// Test implementation
	})
})
```

## Error Handling

### Runtime Assertions

```typescript
import { invariant } from 'tiny-invariant' // Auto-imported

// Use for critical runtime checks
invariant(track, 'Track must be defined')
invariant(tracks.length > 0, 'Must have tracks to play')
```

### Error Boundaries

```svelte
<!-- +error.svelte for route-level errors -->
<script>
	import { page } from '$app/state'
	const { error } = $props()
</script>

<h1>Something went wrong</h1><p>{error.message}</p>
```

### Graceful Degradation

```typescript
// Feature detection
if ('showDirectoryPicker' in window) {
	// Use File System Access API
} else {
	// Fallback to File API
}
```

## Development Workflow

### Commands

```bash
# Development
pnpm run dev          # Start dev server

# Building
pnpm run build        # Production build
pnpm run preview      # Preview build

# Code Quality
pnpm run i18n-check   # Validate translations in messages/*.json
pnpm run type-check   # Type checking
pnpm run biome-check  # Linting
pnpm run biome-fix    # Fix linting issues

# Testing
pnpm run test         # Run tests
```

### Code Quality Rules

#### Always Do ✅

- Use pnpm when running commands
- Leverage auto-imports for common utilities
- Use design system tokens, never arbitrary values
- Apply `.interactable` class to all clickable elements
- Leverage auto-imported utilities (don't import them)
- Use Svelte 5 runes for reactive state
- Type everything explicitly - avoid `any` types
- Handle loading and error states
- Include accessibility attributes
- Use `invariant()` for runtime checks
- Clear test mocks in `afterEach`
- Run `pnpm run i18n-check` after adding/changing i18n keys
- Keep i18n placeholders exactly aligned with English keys (`{count}`, `{name}`, etc.)

#### Never Do ❌

- Use arbitrary Tailwind classes for colors/spacing
- Import auto-imported utilities (m, usePlayer, useMainStore, etc.)
- Skip TypeScript strict mode checks
- Ignore accessibility requirements
- Add server-side dependencies except in `+server.ts` files
- Use `any` types except for complex generics
- Skip error handling
- Hardcode strings (use i18n messages)

### File Naming Conventions

- **Components**: `PascalCase.svelte`
- **Routes**: `+page.svelte`, `+layout.svelte`, `+page.ts`
- **Types**: `kebab-case.ts`
- **Stores**: `kebab-case.svelte.ts`

## Key Files Reference

### Configuration

- `vite.config.ts` - Build and auto-import configuration
- `svelte.config.js` - SvelteKit configuration
- `biome.jsonc` - Code quality rules

### Core Application

- `src/app.css` - Design system and global styles
- `src/theme-colors.css` - Color design tokens (camelCase names)
- `src/app.d.ts` - Global TypeScript definitions
- `src/app.html` - HTML template
- `src/lib/stores/` - Global state management
- `src/lib/db/database.ts` - IndexedDB setup

## Marketing Copy

For landing-page edits under `src/routes/(marketing)/`, follow the colocated guidance in `src/routes/(marketing)/AGENTS.md` and `src/routes/(marketing)/TONE_OF_VOICE.md`.


================================================
FILE: LICENSE.txt
================================================
MIT License

Copyright (c) 2019 Justinas Delinda

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Snae Player

**[snaeplayer.com](https://snaeplayer.com)** - Local music player in the browser.

Play audio files stored on your device. Includes playlists, queue, favorites, equalizer, playback speed, and artwork-based theming.

<p align="center">
  <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" />
</p>

## Browser support

Works in all modern browsers. When the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) is available, currently Chromium-based browsers, the app reads files directly from your chosen folder. In other browsers, files are copied into IndexedDB, which doubles the storage used.

## Privacy

Your music files and library data stay on your device. The app does not collect or transmit them.

Page views are counted using [GoatCounter](https://goatcounter.com/), a minimal privacy-preserving analytics tool.

## Tech stack

SvelteKit/Svelte 5 · TypeScript · Tailwind CSS 4

## Building locally

Clone the repo, then:

```
pnpm install
pnpm run build
```

Or run the development server:

```
pnpm run dev
```


================================================
FILE: biome.jsonc
================================================
{
	"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
	"vcs": {
		"enabled": true,
		"clientKind": "git",
		"defaultBranch": "main",
		"useIgnoreFile": true
	},
	"assist": {
		"actions": {
			"source": {
				"organizeImports": {
					"level": "on",
					"options": {
						"groups": [
							":URL:",
							":NODE:",
							":PACKAGE:",
							":PACKAGE_WITH_PROTOCOL:",
							"$app/**",
							"$server/**",
							"$lib/**",
							":ALIAS:",
							":PATH:"
						]
					}
				}
			}
		}
	},
	"files": {
		"includes": [
			"**",
			"!**/.generated",
			// Biome parser incorrectly errors on TS syntax.
			"!src/lib/components/VirtualContainer.svelte",
			"!static/supported-browser-check.js"
		]
	},
	"formatter": {
		"enabled": true,
		"lineWidth": 100,
		"includes": ["**", "!**/*.svelte", "**/*.svelte.ts"]
	},
	"linter": {
		"enabled": true,
		"domains": {
			"project": "recommended"
		},
		"rules": {
			"style": {
				"noNegationElse": "error",
				"useBlockStatements": "error",
				"useCollapsedElseIf": "error",
				"useConsistentArrayType": {
					"level": "error",
					"options": {
						"syntax": "shorthand"
					}
				},
				"useShorthandAssign": "error",
				"useFilenamingConvention": {
					"level": "error",
					"options": {
						"requireAscii": true,
						"filenameCases": ["kebab-case", "export", "PascalCase"]
					}
				},
				"useThrowNewError": "error",
				"useThrowOnlyError": "error",
				"useConsistentBuiltinInstantiation": "error",
				"useLiteralEnumMembers": "error",
				"useNodejsImportProtocol": "error",
				"useAsConstAssertion": "error",
				"useEnumInitializers": "error",
				"useSelfClosingElements": "error",
				"useSingleVarDeclarator": "error",
				"noUnusedTemplateLiteral": "error",
				"useNumberNamespace": "error",
				"noInferrableTypes": "error",
				"useExponentiationOperator": "error",
				"useTemplate": "error",
				"noParameterAssign": "error",
				"noNonNullAssertion": "error",
				"useDefaultParameterLast": "error",
				"useExportType": "error",
				"noUselessElse": "error",
				"useShorthandFunctionType": "error",
				"useNumericSeparators": "error",
				"noSubstr": "error",
				"useTrimStartEnd": "error",
				"useObjectSpread": "error",
				"useGroupedAccessorPairs": "error",
				"useForOf": "error",
				"useDeprecatedReason": "error",
				"useAtIndex": "error",
				"noYodaExpression": "error",
				"useConsistentArrowReturn": "error",
				"useArrayLiterals": "error",
				"useCollapsedIf": "error",
				"useConsistentTypeDefinitions": "error",
				"useExplicitLengthCheck": "error",
				"noShoutyConstants": "error",
				"noRestrictedImports": {
					"level": "error",
					"options": {
						"paths": {
							"@material/material-color-utilities": "Should not be used directly except in specific theme entrypoints to avoid breaking making big chunks"
						}
					}
				},
				"useConsistentObjectDefinitions": "error"
			},
			"correctness": {
				"noUndeclaredVariables": "error",
				"useImportExtensions": "error",
				"noPrivateImports": {
					"level": "error",
					"options": {
						"defaultVisibility": "package"
					}
				},
				"useSingleJsDocAsterisk": "error",
				"noUnknownFunction": {
					"level": "on",
					"options": {
						"ignore": ["theme"]
					}
				}
			},
			"nursery": {
				"noFloatingPromises": "error",
				"noMisusedPromises": "error",
				"noNestedPromises": "error",
				"noIncrementDecrement": "error",
				"noUnnecessaryConditions": "error",
				"noUselessReturn": "error",
				"useConsistentMethodSignatures": "error"
			},
			"complexity": {
				"useSimplifiedLogicExpression": "error",
				"useNumericLiterals": "error",
				"noCommaOperator": "error",
				"noImportantStyles": "off"
			},
			"suspicious": {
				"useAwait": "error",
				"useErrorMessage": "error",
				"noConsole": {
					"level": "error",
					"options": {
						"allow": ["assert", "error", "info", "warn", "time", "timeEnd", "debug"]
					}
				},
				"noUnknownAtRules": {
					"level": "info",
					"options": {
						"ignore": ["slot"]
					}
				},
				"noImportCycles": "error",
				"noDeprecatedImports": "on"
			},
			"a11y": {
				"noSvgWithoutTitle": "off"
			},
			"performance": {
				"useTopLevelRegex": "error"
			}
		}
	},
	"javascript": {
		"formatter": {
			"semicolons": "asNeeded",
			"quoteStyle": "single",
			"jsxQuoteStyle": "single",
			"indentWidth": 4
		},
		"globals": [
			"m",
			"invariant",
			"usePlayer",
			"useMainStore",
			"useDialogsStore",
			"useMenu",
			"untrack",
			"snackbar"
		]
	},
	"json": {
		"formatter": {
			"indentWidth": 2
		}
	},
	"css": {
		"parser": {
			"tailwindDirectives": true
		},
		"formatter": {
			"enabled": true,
			"quoteStyle": "single"
		}
	},
	"html": {
		"experimentalFullSupportEnabled": true,
		"formatter": {
			"enabled": true
		}
	},
	"overrides": [
		{
			"includes": ["**/*.svelte", "!**/*.svelte.ts"],
			"linter": {
				"rules": {
					"correctness": {
						"noUnusedVariables": "off",
						"noUnusedFunctionParameters": "off"
					},
					"complexity": {
						"noCommaOperator": "off"
					}
				}
			}
		},
		{
			"includes": ["**/src/params/**"],
			"linter": {
				"rules": {
					"style": {
						"useFilenamingConvention": "off"
					}
				}
			}
		},
		{
			"includes": ["**/*.test.ts"],
			"linter": {
				"rules": {
					"correctness": {
						"noPrivateImports": {
							"level": "error",
							"options": {
								"defaultVisibility": "public"
							}
						}
					}
				}
			}
		},
		{
			"includes": ["messages/*.json", "src/lib/components/icon/icon-paths.server.ts"],
			"assist": {
				"actions": {
					"source": {
						"useSortedKeys": {
							"level": "on",
							"options": {}
						}
					}
				}
			}
		}
	]
}


================================================
FILE: knip.json
================================================
{
	"$schema": "https://unpkg.com/knip@6/schema.json",
	"tags": ["-lintignore"],
	"entry": [
		"src/routes/**/*.{svelte,ts}",
		"src/lib/stores/**/*",
		".generated/types/auto-imports.d.ts"
	],
	"project": ["**/*", "!src/paraglide/**/*", "!static/supported-browser-check.js"],
	"ignoreExportsUsedInFile": {
		"interface": true,
		"type": true
	},
	"ignoreUnresolved": ["^\\./\\$types\\.ts$"]
}


================================================
FILE: lib/vite-image-metadata.ts
================================================
import fs from 'node:fs'
import path from 'node:path'
import { imageSizeFromFile } from 'image-size/fromFile'
import type { Plugin } from 'vite'

const imageQuery = '?as=metadata'
const allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']

const queryRegex = /\?as=metadata$/

/** @public */
export function imageMetadataPlugin(): Plugin {
	return {
		name: 'vite-plugin-image-metadata',
		enforce: 'pre',
		load: {
			filter: {
				id: queryRegex,
			},
			async handler(id) {
				const filePath = id.replace(imageQuery, '')

				const ext = path.extname(filePath).slice(1)
				if (!allowedExts.includes(ext)) {
					return
				}

				if (!fs.existsSync(filePath)) {
					return
				}

				const dimensions = await imageSizeFromFile(filePath)

				return `
          import src from "${filePath}?url";

          export const width = ${dimensions.width};
          export const height = ${dimensions.height};
          export { src };

          export default { src, width, height };
        `
			},
		},
	}
}


================================================
FILE: lib/vite-log-chunk-size.ts
================================================
import { readdirSync, statSync } from 'node:fs'
import path from 'node:path'
import type { Plugin } from 'vite'

/** @public */
export const logChunkSizePlugin = (): Plugin => ({
	name: 'vite-plugin-log-chunk-size',
	apply: 'build',
	enforce: 'post',
	writeBundle() {
		if (this.environment.name === 'ssr') {
			return
		}

		const dirSize = async (directory: string) => {
			const jsInfo = { size: 0, count: 0 }
			const totalInfo = { size: 0, count: 0 }

			const processDirectory = async (dir: string) => {
				const files = readdirSync(dir)

				for (const file of files) {
					const filePath = path.join(dir, file)
					const stat = statSync(filePath)

					if (stat.isDirectory()) {
						await processDirectory(filePath)
					} else {
						if (file.endsWith('.js')) {
							jsInfo.size += stat.size
							jsInfo.count += 1
						}
						totalInfo.size += stat.size
						totalInfo.count += 1
					}
				}
			}

			await processDirectory(directory)

			return { jsInfo, totalInfo }
		}

		setTimeout(async () => {
			const { jsInfo, totalInfo } = await dirSize('./build/_app/immutable')
			console.info('Size of JS chunks:', jsInfo.size / 1024, 'KB. Files count:', jsInfo.count)
			console.info(
				'Size of all files:',
				totalInfo.size / 1024,
				'KB. Files count:',
				totalInfo.count,
			)
		}, 2000)
	},
})


================================================
FILE: messages/de.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",

	"about": "Über",
	"aboutHomepage": "Webseite",
	"aboutJoinDiscord": "Discord",
	"aboutPrivacy": "Privatsphäre",
	"aboutSourceCode": "Quellcode",

	"album": "Album",
	"albums": "Alben",

	"appName": "Snae Player",
	"appNameShort": "Snae",
	"appUpdateAvailable": "Neue Version verfügbar",

	"artist": "Künstler",
	"artists": "Künstler",
	"cancel": "Abbrechen",
	"created": "Erstellt",
	"description": "Beschreibung",
	"directoryIsIncludedInParent": "\"{newDir}\" ist ein Unterverzeichnis von \"{existingDir}\" das bereits in Ihrer Bibliothek vorhanden ist. Eine erneute Hinzufügung ist nicht erforderlich.",
	"dismiss": "Schließen",
	"duration": "Dauer",
	"equalizerClose": "Schließen",
	"equalizerOpenEqualizer": "Equalizer öffnen",
	"equalizerPresetAcoustic": "Akustisch",
	"equalizerPresetBassBoost": "Bassverstärkung",
	"equalizerPresetClassical": "Klassik",
	"equalizerPresetElectronic": "Elektronisch",
	"equalizerPresetFlat": "Neutral",
	"equalizerPresetJazz": "Jazz",
	"equalizerPresetPop": "Pop",
	"equalizerPresetRock": "Rock",
	"equalizerPresetTrebleBoost": "Höhenverstärkung",
	"equalizerReset": "Zurücksetzen",
	"equalizerStatusEnabled": "Aktiviert",
	"equalizerTitle": "Equalizer",

	"errorPageDoesNotExist": "Diese Seite scheint nicht zu existieren.",
	"errorUnexpected": "Es ist ein unerwarteter Fehler aufgetreten.",
	"favorites": "Favoriten",
	"foundAnIssue": "Haben Sie einen Fehler entdeckt?",
	"goBack": "Zurück",
	"goHome": "Zur Startseite",
	"library": "Bibliothek",
	"libraryAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
	"libraryApplicationMenu": "App-Menü",
	"libraryCancel": "Abbrechen",
	"libraryConfirmRemoveMultipleTitle": "Sind Sie sicher, dass Sie diese {count} Elemente entfernen möchten?",
	"libraryConfirmRemoveTitle": "Sind Sie sicher, dass Sie \"{name}\" entfernen möchten?",
	"libraryCreate": "Erstellen",
	"libraryCreateNewPlaylist": "Neue Playlist",
	"libraryDirPromptBrowserPermission": "Browserberechtigung erforderlich",
	"libraryDirPromptExplanation": "Zum Abspielen von Musik benötigt die App Zugriff auf folgende Verzeichnisse:",
	"libraryDirPromptGrant": "Zulassen",

	"libraryEditPlaylist": "Wiedergabeliste bearbeiten",
	"libraryEditPlaylistName": "Wiedergabelistenname bearbeiten",
	"libraryEmpty": "Ihre Bibliothek ist leer",
	"libraryImportTracks": "Titel importieren",
	"libraryItemRemovedFromLibrary": "Element aus der Bibliothek entfernt",
	"libraryItemsRemovedFromLibrary": "Elemente aus der Bibliothek entfernt",
	"libraryNewPlaylist": "Neue Wiedergabeliste",

	"libraryNoResults": "Keine Ergebnisse gefunden",
	"libraryNoResultsExplanation": "Versuchen Sie es mit einer anderen Suche",
	"libraryOpenApplicationMenu": "Verwaltung",
	"libraryOpenSortMenu": "Sortieren nach",
	"libraryPlaylistCreated": "Wiedergabeliste \"{playlistName}\" erstellt",
	"libraryPlaylistFieldName": "Wiedergabelistenname",
	"libraryPlaylistName": "Wiedergabelistenname",
	"libraryPlaylistRemoved": "Wiedergabeliste entfernt",
	"libraryPlaylistsUpdated": "Wiedergabelisten aktualisiert",
	"libraryPlaylistUpdated": "Wiedergabelisten aktualisiert",
	"libraryRemove": "Entfernen",
	"libraryRemoveFromLibrary": "Aus der Bibliothek entfernen",
	"librarySave": "Speichern",

	"librarySearch": "Suchen",
	"librarySelectSomethingToBeShown": "Bitte wählen Sie einen Listeneintrag aus, der hier angezeigt werden soll",
	"librarySplitViewDisable": "Geteilte Ansicht deaktivieren",
	"librarySplitViewEnable": "Geteilte Ansicht aktivieren",
	"libraryStartByAdding": "Fügen Sie jetzt Musik hinzu",
	"libraryToggleSortOrder": "Sortierreihenfolge ändern",
	"libraryTrackRemovedFromPlaylist": "Titel aus der Wiedergabeliste entfernt",

	"libraryTrackRemoveFromPlaylist": "Aus Playlist entfernen",
	"libraryTracksCount": "{count} Titel",
	"libraryViewDetails": "Details anzeigen",
	"more": "Mehr",
	"moreOptions": "Weitere Optionen",
	"name": "Name",
	"noItemsToDisplay": "Keine Elemente vorhanden",
	"pause": "Pause",
	"play": "Abspielen",

	"player": "Player",
	"playerAddToQueue": "Zur Warteschlange hinzufügen",
	"playerAudioErrorLoadError": "Audio für \"{name}\" konnte nicht geladen werden",
	"playerAudioErrorNotFound": "Audiodatei für \"{name}\" nicht gefunden. Sie wurde möglicherweise verschoben oder gelöscht.",
	"playerAudioErrorPermissionDenied": "Keine Berechtigung, Audio für \"{name}\" zu laden. Bitte erteilen Sie die Browser-Berechtigung und versuchen Sie es erneut.",
	"playerClearHistory": "Verlauf löschen",
	"playerClearQueue": "Warteschlange löschen",
	"playerDecreaseVolume": "Lautstärke verringern",
	"playerDisableRepeat": "Wiederholung deaktivieren",
	"playerDisableShuffle": "Zufallswiedergabe deaktivieren",
	"playerEnableRepeat": "Wiederholung aktivieren",
	"playerEnableRepeatOne": "Einzelwiederholung aktivieren",
	"playerEnableShuffle": "Zufallswiedergabe aktivieren",
	"playerHistory": "Verlauf",
	"playerHistoryEmpty": "Ihr Wiedergabeverlauf ist leer",
	"playerIncreaseVolume": "Lautstärke erhöhen",
	"playerOpenFullPlayer": "Vollansicht öffnen",
	"playerOpenHistory": "Verlauf öffnen",
	"playerOpenQueue": "Warteschlange öffnen",
	"playerPause": "Pause",
	"playerPlay": "Abspielen",
	"playerPlayNextTrack": "Nächsten Titel abspielen",
	"playerPlayPreviousTrack": "Vorherigen Titel abspielen",
	"playerQueueEmpty": "Ihre Warteschlange ist leer",
	"playerQueuePlaySomething": "Musik entdecken",
	"playerRemoveFromHistory": "Aus Verlauf entfernen",
	"playerRemoveFromQueue": "Aus der Warteschlange entfernen",
	"playlist": "Wiedergabeliste",
	"playlists": "Wiedergabelisten",
	"queue": "Warteschlange",
	"reload": "Aktualisieren",
	"replace": "Ersetzen",
	"replaceDirectoryExplanation": "{newDir} ist ein übergeordnetes Verzeichnis von {existingDirs} und bereits in Ihrer Bibliothek enthalten.\n Bestehende Titel in Ihrer Bibliothek bleiben unverändert.",

	"replaceDirectoryQ": "Ordner ersetzen?",
	"selectAll": "Alle auswählen",
	"selectedCount": "{count} ausgewählt",
	"settingPickColorFromArtwork": "Farbanpassung basierend auf dem aktuellen Titel",
	"settings": "Einstellungen",
	"settingsAbout": "Über",

	"settingsAddDirectory": "Ordner hinzufügen",

	"settingsAllDataLocal": "Alle Daten bleiben lokal auf Ihrem Gerät",
	"settingsAppearance": "Oberflächendesign",
	"settingsApplicationTheme": "Erscheinungsbild",
	"settingsColorPick": "Farbwahl",
	"settingsColorReset": "Zurücksetzen",
	"settingsDbOperationInProgress": "Datenbankvorgang läuft ...",
	"settingsDirectories": "Ordner",
	"settingsDirectoriesTracksCount": "{count} Titel",
	"settingsDirectoryRemoved": "Ordner entfernt",
	"settingsDirRemove": "Entfernen",
	"settingsDirRescan": "Erneut scannen",
	"settingsDisplayVolumeSlider": "Lautstärkeregler im Player anzeigen",
	"settingsGrantDirectoryAccess": "Bitte erlauben Sie der App den Ordnerzugriff über die Browser-Berechtigungen, damit die Inhalte gescannt werden können",
	"settingsImportTracks": "Titel importieren",
	"settingsInstallAppDesktop": "Desktop",
	"settingsInstallAppExplanation": "Fügen Sie den Snae Player zu Ihrem {device} hinzu, um ein noch intensiveres Erlebnis zu erhalten",
	"settingsInstallAppHomeAction": "Installieren",
	"settingsInstallAppHomeScreen": "Startbildschirm",
	"settingsLanguage": "Sprachen",
	"settingsMissingFs1": "Ihr Browser unterstützt nicht die erforderlichen ",
	"settingsMissingFs2": "Dateisystemfunktionen,",
	"settingsMissingFs3": " für den vollständigen Ordnerzugriff, damit diese App funktioniert,",
	"settingsMissingFs4": "jede Musikdatei muss kopiert und im App-Speicher gespeichert werden,",
	"settingsMissingFs5": " dies könnte viel Speicherplatz auf Ihrem Gerät beanspruchen.",
	"settingsMotion": "Animation",
	"settingsMotionAuto": "Auto",
	"settingsMotionNormal": "Normal",
	"settingsMotionReduced": "Reduziert",
	"settingsPlaybackSpeed": "Wiedergabegeschwindigkeit",
	"settingsPlaybackSpeedReset": "Geschwindigkeit zurücksetzen",
	"settingsPreparingForScan": "Scan wird vorbereitet",
	"settingsPreservePitch": "Tonhöhe beibehalten",
	"settingsPreservePitchInfo": "Hält Stimmen und Instrumente in ihrer ursprünglichen Tonlage, während sich die Wiedergabegeschwindigkeit ändert.",
	"settingsPrimaryColor": "Primärfarbe der App",
	"settingsScanInProgress": "Titel werden gescannt. {current} von {total}",
	"settingsScanNewOrUpdatedTracks": "{newTracks} neue oder aktualisierte Titel gefunden",
	"settingsScanNoNewTracks": "Keine neuen Titel gefunden",
	"settingsThemeAuto": "Auto",
	"settingsThemeDark": "Dunkel",
	"settingsThemeLight": "Hell",
	"settingsTracksInAppStorageTooltip": "Dies enthält im App-Speicher gespeicherte Titel und/oder Daten, die Sie aus Snae Player V1 migriert haben",
	"settingsTracksInsideAppMemory": "Titel im App-Speicher",
	"shuffle": "Zufallswiedergabe",
	"successfullyRemovedTracks": "{count} Titel erfolgreich entfernt",
	"track": "Titel",
	"trackAddToFavorites": "Zu Favoriten hinzufügen",

	"trackPlay": "{name} abspielen",
	"trackRemoveFromFavorites": "Aus den Favoriten entfernen",
	"tracks": "Titel",
	"trackViewAlbum": "Album anzeigen",
	"trackViewArtist": "Künstler anzeigen",
	"understood": "Verstanden",
	"unknown": "Unbekannt",
	"validationMaxLength": "Es sind maximal {max} Zeichen erlaubt",
	"validationMinLength": "Es sind mindestens {min} Zeichen erforderlich",

	"validationRequired": "Eingabe erforderlich",
	"year": "Jahr"
}


================================================
FILE: messages/en.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",

	"about": "About",
	"aboutHomepage": "Homepage",
	"aboutJoinDiscord": "Join our Discord",
	"aboutPrivacy": "Privacy",
	"aboutSourceCode": "Source code",

	"album": "Album",
	"albums": "Albums",

	"appName": "Snae Player",
	"appNameShort": "Snae",
	"appUpdateAvailable": "App update is available",

	"artist": "Artist",
	"artists": "Artists",
	"cancel": "Cancel",
	"created": "Created",
	"description": "Description",
	"directoryIsIncludedInParent": "\"{newDir}\" is subdirectory of \"{existingDir}\" which is already in your Library. You do not need to add it again.",
	"dismiss": "Dismiss",
	"duration": "Duration",

	"equalizerClose": "Close",
	"equalizerOpenEqualizer": "Open equalizer",
	"equalizerPresetAcoustic": "Acoustic",
	"equalizerPresetBassBoost": "Bass Boost",
	"equalizerPresetClassical": "Classical",
	"equalizerPresetElectronic": "Electronic",
	"equalizerPresetFlat": "Flat",
	"equalizerPresetJazz": "Jazz",
	"equalizerPresetPop": "Pop",
	"equalizerPresetRock": "Rock",
	"equalizerPresetTrebleBoost": "Treble Boost",
	"equalizerReset": "Reset",
	"equalizerStatusEnabled": "Enabled",
	"equalizerTitle": "Equalizer",

	"errorPageDoesNotExist": "Looks like this page doesn't exist.",
	"errorUnexpected": "An unexpected error occurred.",
	"favorites": "Favorites",
	"foundAnIssue": "Found an issue?",
	"goBack": "Go back",
	"goHome": "Go home",
	"library": "Library",
	"libraryAddToPlaylist": "Add to playlist",
	"libraryApplicationMenu": "Application menu",
	"libraryCancel": "Cancel",
	"libraryConfirmRemoveMultipleTitle": "Are you sure you want to remove these {count} items?",
	"libraryConfirmRemoveTitle": "Are you sure you want to remove \"{name}\"?",
	"libraryCreate": "Create",
	"libraryCreateNewPlaylist": "Create new playlist",
	"libraryDirPromptBrowserPermission": "Browser permission required",
	"libraryDirPromptExplanation": "To play music, the app needs permission to access these directories:",
	"libraryDirPromptGrant": "Grant",

	"libraryEditPlaylist": "Edit playlist",
	"libraryEditPlaylistName": "Edit playlist name",
	"libraryEmpty": "Your library is empty",
	"libraryImportTracks": "Import tracks",
	"libraryItemRemovedFromLibrary": "Item removed from library",
	"libraryItemsRemovedFromLibrary": "Items removed from library",
	"libraryNewPlaylist": "New playlist",

	"libraryNoResults": "No results found",
	"libraryNoResultsExplanation": "Try searching for something else",
	"libraryOpenApplicationMenu": "Open application menu",
	"libraryOpenSortMenu": "Open sort menu",
	"libraryPlaylistCreated": "Playlist \"{playlistName}\" created",
	"libraryPlaylistFieldName": "playlist name",
	"libraryPlaylistName": "Playlist name",
	"libraryPlaylistRemoved": "Playlist removed",
	"libraryPlaylistsUpdated": "Playlists updated",
	"libraryPlaylistUpdated": "Playlist updated",
	"libraryRemove": "Remove",
	"libraryRemoveFromLibrary": "Remove from library",
	"librarySave": "Save",

	"librarySearch": "Search",
	"librarySelectSomethingToBeShown": "Select something from the list to be shown here",
	"librarySplitViewDisable": "Disable split view layout",
	"librarySplitViewEnable": "Enable split view layout",
	"libraryStartByAdding": "Start by adding some music",
	"libraryToggleSortOrder": "Toggle sort order",
	"libraryTrackRemovedFromPlaylist": "Track removed from playlist",

	"libraryTrackRemoveFromPlaylist": "Remove from playlist",
	"libraryTracksCount": "{count} tracks",
	"libraryViewDetails": "View details",
	"more": "More",
	"moreOptions": "More options",
	"name": "Name",
	"noItemsToDisplay": "No items to display",
	"pause": "Pause",
	"play": "Play",

	"player": "Player",
	"playerAddToQueue": "Add to queue",
	"playerAudioErrorLoadError": "Failed to load audio for \"{name}\"",
	"playerAudioErrorNotFound": "Audio file not found for \"{name}\". It might have been moved or deleted.",
	"playerAudioErrorPermissionDenied": "Permission denied to load audio for \"{name}\". Please grant browser permission and try again.",
	"playerClearHistory": "Clear history",
	"playerClearQueue": "Clear queue",
	"playerDecreaseVolume": "Decrease volume",
	"playerDisableRepeat": "Disable repeat",
	"playerDisableShuffle": "Disable shuffle",
	"playerEnableRepeat": "Enable repeat",
	"playerEnableRepeatOne": "Enable repeat one",
	"playerEnableShuffle": "Enable shuffle",
	"playerHistory": "History",
	"playerHistoryEmpty": "Your play history is empty",
	"playerIncreaseVolume": "Increase volume",
	"playerOpenFullPlayer": "Open full player",
	"playerOpenHistory": "Open history",
	"playerOpenQueue": "Open queue",
	"playerPause": "Pause",
	"playerPlay": "Play",
	"playerPlayNextTrack": "Play next track",
	"playerPlayPreviousTrack": "Play previous track",
	"playerQueueEmpty": "Your queue is empty",
	"playerQueuePlaySomething": "Play something here",
	"playerRemoveFromHistory": "Remove from history",
	"playerRemoveFromQueue": "Remove from queue",
	"playlist": "Playlist",
	"playlists": "Playlists",
	"queue": "Queue",
	"reload": "Reload",
	"replace": "Replace",
	"replaceDirectoryExplanation": "The directory {newDir} you're adding is a parent of {existingDirs} which already exists in your Library.\n No changes will occur to existing tracks in your library.",

	"replaceDirectoryQ": "Replace directory?",
	"selectAll": "Select all",
	"selectedCount": "Selected {count}",
	"settingPickColorFromArtwork": "Automatically pick color from currently playing song artwork",
	"settings": "Settings",
	"settingsAbout": "About",

	"settingsAddDirectory": "Add directory",

	"settingsAllDataLocal": "All data is kept on your device",
	"settingsAppearance": "Appearance",
	"settingsApplicationTheme": "Application theme",
	"settingsColorPick": "Pick color",
	"settingsColorReset": "Reset",
	"settingsDbOperationInProgress": "Database operation in progress...",
	"settingsDirectories": "Directories",
	"settingsDirectoriesTracksCount": "{count} tracks",
	"settingsDirectoryRemoved": "Directory removed",
	"settingsDirRemove": "Remove",
	"settingsDirRescan": "Rescan",
	"settingsDisplayVolumeSlider": "Display volume slider inside player",
	"settingsGrantDirectoryAccess": "You need to allow the app access to the directory, via browser permission so it can scan its contents",
	"settingsImportTracks": "Import tracks",
	"settingsInstallAppDesktop": "desktop",
	"settingsInstallAppExplanation": "Add Snae Player to your {device} for more immersive experience",
	"settingsInstallAppHomeAction": "Install",
	"settingsInstallAppHomeScreen": "home screen",
	"settingsLanguage": "Language",
	"settingsMissingFs1": "Your browser does not support required ",
	"settingsMissingFs2": "File System features,",
	"settingsMissingFs3": " for full directory access so in order for this app to work,",
	"settingsMissingFs4": "each music file must be copied and saved inside app storage,",
	"settingsMissingFs5": " that might take up a lot of your device's disk space.",
	"settingsMotion": "Motion",
	"settingsMotionAuto": "Auto",
	"settingsMotionNormal": "Normal",
	"settingsMotionReduced": "Reduced",
	"settingsPlaybackSpeed": "Playback speed",
	"settingsPlaybackSpeedReset": "Reset speed",
	"settingsPreparingForScan": "Preparing for the scan",
	"settingsPreservePitch": "Preserve pitch",
	"settingsPreservePitchInfo": "Keeps voices and instruments at their original tone while changing playback speed.",
	"settingsPrimaryColor": "Application primary color",
	"settingsScanInProgress": "Scanning tracks. {current} of {total}",
	"settingsScanNewOrUpdatedTracks": "Found {newTracks} new or updated tracks",
	"settingsScanNoNewTracks": "No new tracks were found",
	"settingsThemeAuto": "Auto",
	"settingsThemeDark": "Dark",
	"settingsThemeLight": "Light",
	"settingsTracksInAppStorageTooltip": "This contains tracks stored in app storage and/or data you migrated from Snae Player v1",
	"settingsTracksInsideAppMemory": "Tracks inside app storage",

	"shuffle": "Shuffle",
	"successfullyRemovedTracks": "Successfully removed {count} tracks",
	"track": "Track",
	"trackAddToFavorites": "Add to favorites",

	"trackPlay": "Play {name}",
	"trackRemoveFromFavorites": "Remove from favorites",
	"tracks": "Tracks",
	"trackViewAlbum": "View album",
	"trackViewArtist": "View artist",
	"understood": "Understood",
	"unknown": "Unknown",
	"validationMaxLength": "At most {max} characters are allowed",
	"validationMinLength": "At least {min} characters are required",

	"validationRequired": "Field is required",
	"year": "Year"
}


================================================
FILE: messages/fr.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",
	"about": "À propos",
	"aboutHomepage": "Page d’accueil",
	"aboutJoinDiscord": "Rejoindre notre Discord",
	"aboutPrivacy": "Confidentialité",
	"aboutSourceCode": "Code source",
	"album": "Album",
	"albums": "Albums",
	"appName": "Snae Player",
	"appNameShort": "Snae",
	"appUpdateAvailable": "Une mise à jour est disponible",
	"artist": "Artiste",
	"artists": "Artistes",
	"cancel": "Annuler",
	"created": "Créé",
	"description": "Description",
	"directoryIsIncludedInParent": "« {newDir} » est un sous-dossier de « {existingDir} » qui est déjà dans votre bibliothèque. Vous n’avez pas besoin de l’ajouter à nouveau.",
	"dismiss": "Fermer",
	"duration": "Durée",
	"equalizerClose": "Fermer",
	"equalizerOpenEqualizer": "Ouvrir l'égaliseur",
	"equalizerPresetAcoustic": "Acoustique",
	"equalizerPresetBassBoost": "Renforcement des basses",
	"equalizerPresetClassical": "Classique",
	"equalizerPresetElectronic": "Électronique",
	"equalizerPresetFlat": "Plat",
	"equalizerPresetJazz": "Jazz",
	"equalizerPresetPop": "Pop",
	"equalizerPresetRock": "Rock",
	"equalizerPresetTrebleBoost": "Renforcement des aigus",
	"equalizerReset": "Réinitialiser",
	"equalizerStatusEnabled": "Activé",
	"equalizerTitle": "Égaliseur",
	"errorPageDoesNotExist": "Il semble que cette page n’existe pas.",
	"errorUnexpected": "Une erreur inattendue s’est produite.",
	"favorites": "Favoris",
	"foundAnIssue": "Vous avez trouvé un problème ?",
	"goBack": "Retour",
	"goHome": "Aller à l'accueil",
	"library": "Bibliothèque",
	"libraryAddToPlaylist": "Ajouter à la liste de lecture",
	"libraryApplicationMenu": "Menu de l’application",
	"libraryCancel": "Annuler",
	"libraryConfirmRemoveMultipleTitle": "Êtes-vous sûr de vouloir supprimer ces {count} éléments ?",
	"libraryConfirmRemoveTitle": "Êtes-vous sûr de vouloir supprimer « {name} » ?",
	"libraryCreate": "Créer",
	"libraryCreateNewPlaylist": "Créer une nouvelle liste de lecture",
	"libraryDirPromptBrowserPermission": "Autorisation du navigateur requise",
	"libraryDirPromptExplanation": "Pour lire la musique, l’application a besoin d’accéder à ces dossiers :",
	"libraryDirPromptGrant": "Autoriser",
	"libraryEditPlaylist": "Modifier la liste de lecture",
	"libraryEditPlaylistName": "Modifier le nom de la liste de lecture",
	"libraryEmpty": "Votre bibliothèque est vide",
	"libraryImportTracks": "Importer des pistes",
	"libraryItemRemovedFromLibrary": "Élément supprimé de la bibliothèque",
	"libraryItemsRemovedFromLibrary": "Éléments supprimés de la bibliothèque",
	"libraryNewPlaylist": "Nouvelle liste de lecture",
	"libraryNoResults": "Aucun résultat trouvé",
	"libraryNoResultsExplanation": "Essayez de rechercher autre chose",
	"libraryOpenApplicationMenu": "Ouvrir le menu de l’application",
	"libraryOpenSortMenu": "Ouvrir le menu de tri",
	"libraryPlaylistCreated": "Liste de lecture « {playlistName} » créée",
	"libraryPlaylistFieldName": "nom de la liste de lecture",
	"libraryPlaylistName": "Nom de la liste de lecture",
	"libraryPlaylistRemoved": "Liste de lecture supprimée",
	"libraryPlaylistsUpdated": "Listes de lecture mises à jour",
	"libraryPlaylistUpdated": "Liste de lecture mise à jour",
	"libraryRemove": "Supprimer",
	"libraryRemoveFromLibrary": "Supprimer de la bibliothèque",
	"librarySave": "Enregistrer",
	"librarySearch": "Rechercher",
	"librarySelectSomethingToBeShown": "Sélectionnez un élément de la liste pour l’afficher ici",
	"librarySplitViewDisable": "Désactiver la vue partagée",
	"librarySplitViewEnable": "Activer la vue partagée",
	"libraryStartByAdding": "Commencez par ajouter de la musique",
	"libraryToggleSortOrder": "Changer l’ordre de tri",
	"libraryTrackRemovedFromPlaylist": "Piste retirée de la liste de lecture",
	"libraryTrackRemoveFromPlaylist": "Retirer de la liste de lecture",
	"libraryTracksCount": "{count} pistes",
	"libraryViewDetails": "Voir les détails",
	"more": "Plus",
	"moreOptions": "Plus d’options",
	"name": "Nom",
	"noItemsToDisplay": "Aucun élément à afficher",
	"pause": "Pause",
	"play": "Lecture",
	"player": "Lecteur",
	"playerAddToQueue": "Ajouter à la file d’attente",
	"playerAudioErrorLoadError": "Impossible de charger l’audio pour \"{name}\"",
	"playerAudioErrorNotFound": "Fichier audio introuvable pour \"{name}\". Il a peut-être été déplacé ou supprimé.",
	"playerAudioErrorPermissionDenied": "Autorisation refusée pour charger l’audio de \"{name}\". Veuillez accorder la permission du navigateur et réessayer.",
	"playerClearHistory": "Effacer l'historique",
	"playerClearQueue": "Vider la file d’attente",
	"playerDecreaseVolume": "Diminuer le volume",
	"playerDisableRepeat": "Désactiver la répétition",
	"playerDisableShuffle": "Désactiver la lecture aléatoire",
	"playerEnableRepeat": "Activer la répétition",
	"playerEnableRepeatOne": "Répéter une seule piste",
	"playerEnableShuffle": "Activer la lecture aléatoire",
	"playerHistory": "Historique",
	"playerHistoryEmpty": "Votre historique d'écoute est vide",
	"playerIncreaseVolume": "Augmenter le volume",
	"playerOpenFullPlayer": "Ouvrir le lecteur en plein écran",
	"playerOpenHistory": "Ouvrir l'historique",
	"playerOpenQueue": "Ouvrir la file d’attente",
	"playerPause": "Pause",
	"playerPlay": "Lecture",
	"playerPlayNextTrack": "Piste suivante",
	"playerPlayPreviousTrack": "Piste précédente",
	"playerQueueEmpty": "Votre file d’attente est vide",
	"playerQueuePlaySomething": "Lancez une lecture ici",
	"playerRemoveFromHistory": "Retirer de l'historique",
	"playerRemoveFromQueue": "Retirer de la file d’attente",
	"playlist": "Liste de lecture",
	"playlists": "Listes de lecture",
	"queue": "File d’attente",
	"reload": "Recharger",
	"replace": "Remplacer",
	"replaceDirectoryExplanation": "Le dossier {newDir} que vous ajoutez contient {existingDirs} qui existe déjà dans votre bibliothèque.\n Aucune modification ne sera apportée aux pistes existantes de votre bibliothèque.",
	"replaceDirectoryQ": "Remplacer le dossier ?",
	"selectAll": "Tout sélectionner",
	"selectedCount": "{count} sélectionnés",
	"settingPickColorFromArtwork": "Choisir automatiquement la couleur à partir de la pochette de la piste en cours",
	"settings": "Paramètres",
	"settingsAbout": "À propos",
	"settingsAddDirectory": "Ajouter un dossier",
	"settingsAllDataLocal": "Toutes les données sont conservées sur votre appareil",
	"settingsAppearance": "Apparence",
	"settingsApplicationTheme": "Thème de l’application",
	"settingsColorPick": "Choisir la couleur",
	"settingsColorReset": "Réinitialiser",
	"settingsDbOperationInProgress": "Opération de base de données en cours…",
	"settingsDirectories": "Dossiers",
	"settingsDirectoriesTracksCount": "{count} pistes",
	"settingsDirectoryRemoved": "Dossier supprimé",
	"settingsDirRemove": "Supprimer",
	"settingsDirRescan": "Réanalyser",
	"settingsDisplayVolumeSlider": "Afficher le curseur de volume dans le lecteur",
	"settingsGrantDirectoryAccess": "Vous devez autoriser l’appli à accéder au dossier via une permission du navigateur afin qu’elle puisse analyser son contenu",
	"settingsImportTracks": "Importer des pistes",
	"settingsInstallAppDesktop": "bureau",
	"settingsInstallAppExplanation": "Ajoutez Snae Player à votre {device} pour une expérience plus immersive",
	"settingsInstallAppHomeAction": "Installer",
	"settingsInstallAppHomeScreen": "écran d’accueil",
	"settingsLanguage": "Langue",
	"settingsMissingFs1": "Votre navigateur ne prend pas en charge les ",
	"settingsMissingFs2": "fonctionnalités du système de fichiers,",
	"settingsMissingFs3": " nécessaires pour l’accès complet aux dossiers. L’application doit",
	"settingsMissingFs4": " donc copier chaque fichier musical dans son propre stockage, ce",
	"settingsMissingFs5": " qui peut occuper beaucoup d’espace sur votre appareil.",
	"settingsMotion": "Animation",
	"settingsMotionAuto": "Auto",
	"settingsMotionNormal": "Normal",
	"settingsMotionReduced": "Réduit",
	"settingsPlaybackSpeed": "Vitesse de lecture",
	"settingsPlaybackSpeedReset": "Réinitialiser la vitesse",
	"settingsPreparingForScan": "Préparation de l’analyse",
	"settingsPreservePitch": "Conserver la hauteur",
	"settingsPreservePitchInfo": "Conserve la tonalité d'origine des voix et des instruments lorsque la vitesse de lecture change.",
	"settingsPrimaryColor": "Couleur principale de l’application",
	"settingsScanInProgress": "Analyse des pistes. {current} sur {total}",
	"settingsScanNewOrUpdatedTracks": "{newTracks} nouvelles pistes ou pistes mises à jour trouvées",
	"settingsScanNoNewTracks": "Aucune nouvelle piste trouvée",
	"settingsThemeAuto": "Auto",
	"settingsThemeDark": "Sombre",
	"settingsThemeLight": "Clair",
	"settingsTracksInAppStorageTooltip": "Contient les pistes stockées dans le stockage de l'application et/ou les données migrées depuis Snae Player v1",
	"settingsTracksInsideAppMemory": "Pistes dans le stockage de l’application",
	"shuffle": "Aléatoire",
	"successfullyRemovedTracks": "{count} pistes supprimées avec succès",
	"track": "Piste",
	"trackAddToFavorites": "Ajouter aux favoris",
	"trackPlay": "Lire {name}",
	"trackRemoveFromFavorites": "Retirer des favoris",
	"tracks": "Pistes",
	"trackViewAlbum": "Voir l’album",
	"trackViewArtist": "Voir l’artiste",
	"understood": "Compris",
	"unknown": "Inconnu",
	"validationMaxLength": "{max} caractères maximum autorisés",
	"validationMinLength": "{min} caractères minimum requis",
	"validationRequired": "Champ obligatoire",
	"year": "Année"
}


================================================
FILE: messages/lt.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",

	"about": "Apie",
	"aboutHomepage": "Namai",
	"aboutJoinDiscord": "Discord",
	"aboutPrivacy": "Privatumas",
	"aboutSourceCode": "Programos kodas",

	"album": "Albumas",
	"albums": "Albumai",

	"appName": "Snae grotuvas",
	"appNameShort": "Snae",
	"appUpdateAvailable": "Galimas programos atnaujinimas",

	"artist": "Atlikėjas",
	"artists": "Atlikėjai",
	"cancel": "Atšaukti",
	"created": "Sukurta",
	"description": "Aprašymas",
	"directoryIsIncludedInParent": "\"{newDir}\" yra \"{existingDir}\" poaplankis, kuris jau yra jūsų bibliotekoje. Jums nereikia jo pridėti dar kartą.",
	"dismiss": "Uždaryti",
	"duration": "Trukmė",
	"equalizerClose": "Uždaryti",
	"equalizerOpenEqualizer": "Atidaryti ekvalaizerį",
	"equalizerPresetAcoustic": "Akustinis",
	"equalizerPresetBassBoost": "Paryškinti žemus dažnius",
	"equalizerPresetClassical": "Klasikinis",
	"equalizerPresetElectronic": "Elektroninis",
	"equalizerPresetFlat": "Neutralus",
	"equalizerPresetJazz": "Džiazas",
	"equalizerPresetPop": "Pop",
	"equalizerPresetRock": "Rokas",
	"equalizerPresetTrebleBoost": "Paryškinti aukštus dažnius",
	"equalizerReset": "Atstatyti",
	"equalizerStatusEnabled": "Įjungtas",
	"equalizerTitle": "Ekvalaizeris",

	"errorPageDoesNotExist": "Panašu, kad šio puslapio nėra.",
	"errorUnexpected": "Įvyko netikėta klaida.",
	"favorites": "Mėgstamiausi",
	"foundAnIssue": "Radote klaidą?",
	"goBack": "Grįžti",
	"goHome": "Grįžti į pradžią",
	"library": "Biblioteka",
	"libraryAddToPlaylist": "Pridėti į grojaraštį",
	"libraryApplicationMenu": "Programos meniu",
	"libraryCancel": "Atšaukti",
	"libraryConfirmRemoveMultipleTitle": "Ar tikrai norite pašalinti šiuos {count} elementus?",
	"libraryConfirmRemoveTitle": "Ar tikrai norite pašalinti „{name}“?",
	"libraryCreate": "Sukurti",
	"libraryCreateNewPlaylist": "Sukurti naują grojaraštį",
	"libraryDirPromptBrowserPermission": "Reikalingas naršyklės leidimas",
	"libraryDirPromptExplanation": "Kad galėtumėte klausytis muzikos, programai reikia leidimo pasiekti šiuos katalogus:",
	"libraryDirPromptGrant": "Suteikti",

	"libraryEditPlaylist": "Redaguoti grojaraštį",
	"libraryEditPlaylistName": "Redaguoti grojaraščio pavadinimą",
	"libraryEmpty": "Jūsų biblioteka tuščia",
	"libraryImportTracks": "Importuoti kūrinius",
	"libraryItemRemovedFromLibrary": "Elementas pašalintas iš bibliotekos",
	"libraryItemsRemovedFromLibrary": "Elementai pašalinti iš bibliotekos",
	"libraryNewPlaylist": "Naujas grojaraštis",

	"libraryNoResults": "Rezultatų nerasta",
	"libraryNoResultsExplanation": "Pabandykite ieškoti kitaip",
	"libraryOpenApplicationMenu": "Atidaryti programos meniu",
	"libraryOpenSortMenu": "Atidaryti rūšiavimo meniu",
	"libraryPlaylistCreated": "Grojaraštis „{playlistName}“ sukurtas",
	"libraryPlaylistFieldName": "grojaraščio pavadinimas",
	"libraryPlaylistName": "Grojaraščio pavadinimas",
	"libraryPlaylistRemoved": "Grojaraštis pašalintas",
	"libraryPlaylistsUpdated": "Grojaraščiai atnaujinti",
	"libraryPlaylistUpdated": "Grojaraštis atnaujintas",
	"libraryRemove": "Pašalinti",
	"libraryRemoveFromLibrary": "Pašalinti iš bibliotekos",
	"librarySave": "Išsaugoti",

	"librarySearch": "Paieška",
	"librarySelectSomethingToBeShown": "Pasirinkite ką nors iš sąrašo, kad būtų rodoma čia",
	"librarySplitViewDisable": "Išjungti padalintą išdėstymą",
	"librarySplitViewEnable": "Įjungti padalintą išdėstymą",
	"libraryStartByAdding": "Pradėkite pridėdami muzikos",
	"libraryToggleSortOrder": "Perjungti rūšiavimo tvarką",
	"libraryTrackRemovedFromPlaylist": "Kūrinys pašalintas iš grojaraščio",

	"libraryTrackRemoveFromPlaylist": "Pašalinti iš grojaraščio",
	"libraryTracksCount": "{count} kūriniai",
	"libraryViewDetails": "Peržiūrėti detales",
	"more": "Daugiau",
	"moreOptions": "Daugiau parinkčių",
	"name": "Pavadinimas",
	"noItemsToDisplay": "Nėra elementų rodymui",
	"pause": "Pristabdyti",
	"play": "Groti",

	"player": "Grotuvas",
	"playerAddToQueue": "Pridėti į eilę",
	"playerAudioErrorLoadError": "Nepavyko įkelti garso įrašo \"{name}\"",
	"playerAudioErrorNotFound": "Garso failas \"{name}\" nerastas. Gali būti perkeltas arba ištrintas.",
	"playerAudioErrorPermissionDenied": "Trūksta leidimo įkelti garso įrašą \"{name}\". Suteikite naršyklės leidimą ir bandykite dar kartą.",
	"playerClearHistory": "Išvalyti istoriją",
	"playerClearQueue": "Išvalyti eilę",
	"playerDecreaseVolume": "Sumažinti garsumą",
	"playerDisableRepeat": "Išjungti kartojimą",
	"playerDisableShuffle": "Išjungti maišymą",
	"playerEnableRepeat": "Įjungti kartojimą",
	"playerEnableRepeatOne": "Įjungti vieno kūrinio kartojimą",
	"playerEnableShuffle": "Įjungti maišymą",
	"playerHistory": "Istorija",
	"playerHistoryEmpty": "Jūsų klausymo istorija tuščia",
	"playerIncreaseVolume": "Padidinti garsumą",
	"playerOpenFullPlayer": "Atidaryti pilną grotuvą",
	"playerOpenHistory": "Atidaryti istoriją",
	"playerOpenQueue": "Atidaryti eilę",
	"playerPause": "Pristabdyti",
	"playerPlay": "Groti",
	"playerPlayNextTrack": "Groti kitą kūrinį",
	"playerPlayPreviousTrack": "Groti ankstesnį kūrinį",
	"playerQueueEmpty": "Jūsų eilė tuščia",
	"playerQueuePlaySomething": "Grokite ką nors čia",
	"playerRemoveFromHistory": "Pašalinti iš istorijos",
	"playerRemoveFromQueue": "Pašalinti iš eilės",
	"playlist": "Grojaraštis",
	"playlists": "Grojaraščiai",
	"queue": "Eilė",
	"reload": "Įkelti iš naujo",
	"replace": "Pakeisti",
	"replaceDirectoryExplanation": "Katalogas {newDir}, kurį pridedate, yra {existingDirs} pirminis katalogas, kuris jau egzistuoja jūsų bibliotekoje.\n Esamiems kūriniams bibliotekoje niekas nepasikeis.",

	"replaceDirectoryQ": "Pakeisti katalogą?",
	"selectAll": "Pasirinkti viską",
	"selectedCount": "Pasirinkta: {count}",
	"settingPickColorFromArtwork": "Automatiškai parinkti spalvą pagal šiuo metu grojančio kūrinio viršelį",
	"settings": "Nustatymai",
	"settingsAbout": "Apie",

	"settingsAddDirectory": "Pridėti katalogą",

	"settingsAllDataLocal": "Visi duomenys išsaugomi jūsų įrenginyje",
	"settingsAppearance": "Išvaizda",
	"settingsApplicationTheme": "Programos tema",
	"settingsColorPick": "Pasirinkti spalvą",
	"settingsColorReset": "Atstatyti",
	"settingsDbOperationInProgress": "Vykdoma duomenų bazės operacija...",
	"settingsDirectories": "Katalogai",
	"settingsDirectoriesTracksCount": "{count} kūriniai",
	"settingsDirectoryRemoved": "Katalogas pašalintas",
	"settingsDirRemove": "Pašalinti",
	"settingsDirRescan": "Skenuoti iš naujo",
	"settingsDisplayVolumeSlider": "Rodyti garso slankiklį grotuve",
	"settingsGrantDirectoryAccess": "Reikia suteikti programai prieigą prie katalogo per naršyklės leidimą, kad ji galėtų nuskaityti jo turinį",
	"settingsImportTracks": "Importuoti kūrinius",
	"settingsInstallAppDesktop": "kompiuterį",
	"settingsInstallAppExplanation": "Pridėkite Snae grotuvą prie savo {device} patogesnei patirčiai",
	"settingsInstallAppHomeAction": "Įdiegti",
	"settingsInstallAppHomeScreen": "pagrindinis ekranas",
	"settingsLanguage": "Kalba",
	"settingsMissingFs1": "Jūsų naršyklė nepalaiko reikalingų ",
	"settingsMissingFs2": "Failų sistemos funkcijų,",
	"settingsMissingFs3": " kad būtų galima visiškai pasiekti katalogus, todėl šiai programai veikti,",
	"settingsMissingFs4": "kiekvieną muzikos failą reikia nukopijuoti ir išsaugoti programos saugykloje,",
	"settingsMissingFs5": " tai gali užimti daug vietos jūsų įrenginyje.",
	"settingsMotion": "Animacijos",
	"settingsMotionAuto": "Automatinės",
	"settingsMotionNormal": "Normalios",
	"settingsMotionReduced": "Sumažintos",
	"settingsPlaybackSpeed": "Atkūrimo greitis",
	"settingsPlaybackSpeedReset": "Atstatyti greitį",
	"settingsPreparingForScan": "Ruošiamasi skenavimui",
	"settingsPreservePitch": "Išlaikyti toną",
	"settingsPreservePitchInfo": "Keičiant atkūrimo greitį, balsų ir instrumentų tonas išlieka originalus.",
	"settingsPrimaryColor": "Pagrindinė programos spalva",
	"settingsScanInProgress": "Skenuojami kūriniai. {current} iš {total}",
	"settingsScanNewOrUpdatedTracks": "Rasta {newTracks} naujų arba atnaujintų kūrinių",
	"settingsScanNoNewTracks": "Naujų kūrinių nerasta",
	"settingsThemeAuto": "Automatinė",
	"settingsThemeDark": "Tamsi",
	"settingsThemeLight": "Šviesi",
	"settingsTracksInAppStorageTooltip": "Tai apima kūrinius, saugomus programos saugykloje ir/arba duomenis, perkeltus iš Snae Player v1",
	"settingsTracksInsideAppMemory": "Kūriniai programos saugykloje",

	"shuffle": "Maišyti",
	"successfullyRemovedTracks": "Sėkmingai pašalinta {count} kūrinių",
	"track": "Kūrinys",
	"trackAddToFavorites": "Pridėti prie mėgstamiausių",

	"trackPlay": "Groti {name}",
	"trackRemoveFromFavorites": "Pašalinti iš mėgstamiausių",
	"tracks": "Kūriniai",
	"trackViewAlbum": "Peržiūrėti albumą",
	"trackViewArtist": "Peržiūrėti atlikėją",
	"understood": "Supratau",
	"unknown": "Nežinoma",
	"validationMaxLength": "Leidžiama daugiausiai {max} simbolių",
	"validationMinLength": "Reikalinga bent {min} simbolių",

	"validationRequired": "Laukas yra privalomas",
	"year": "Metai"
}


================================================
FILE: messages/zh-CN.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",

	"about": "关于",
	"aboutHomepage": "主页",
	"aboutJoinDiscord": "加入我们的 Discord",
	"aboutPrivacy": "隐私",
	"aboutSourceCode": "源代码",

	"album": "专辑",
	"albums": "专辑",

	"appName": "Snae 播放器",
	"appNameShort": "Snae",
	"appUpdateAvailable": "应用有可用更新",

	"artist": "艺术家",
	"artists": "艺术家",
	"cancel": "取消",
	"created": "创建时间",
	"description": "描述",
	"directoryIsIncludedInParent": "\"{newDir}\" 是 \"{existingDir}\" 的子目录,而 \"{existingDir}\" 已经在您的媒体库中。您无需再次添加。",
	"dismiss": "忽略",
	"duration": "时长",
	"equalizerClose": "关闭",
	"equalizerOpenEqualizer": "打开均衡器",
	"equalizerPresetAcoustic": "原声",
	"equalizerPresetBassBoost": "低音增强",
	"equalizerPresetClassical": "古典",
	"equalizerPresetElectronic": "电子",
	"equalizerPresetFlat": "平直",
	"equalizerPresetJazz": "爵士",
	"equalizerPresetPop": "流行",
	"equalizerPresetRock": "摇滚",
	"equalizerPresetTrebleBoost": "高音增强",
	"equalizerReset": "重置",
	"equalizerStatusEnabled": "已启用",
	"equalizerTitle": "均衡器",

	"errorPageDoesNotExist": "看起来此页面不存在。",
	"errorUnexpected": "发生了一个意外错误。",
	"favorites": "收藏夹",
	"foundAnIssue": "发现问题?",
	"goBack": "返回",
	"goHome": "回到首页",
	"library": "媒体库",
	"libraryAddToPlaylist": "添加到播放列表",
	"libraryApplicationMenu": "应用菜单",
	"libraryCancel": "取消",
	"libraryConfirmRemoveMultipleTitle": "您确定要移除这 {count} 项吗?",
	"libraryConfirmRemoveTitle": "您确定要移除 \"{name}\" 吗?",
	"libraryCreate": "创建",
	"libraryCreateNewPlaylist": "创建新播放列表",
	"libraryDirPromptBrowserPermission": "需要浏览器权限",
	"libraryDirPromptExplanation": "要播放音乐,应用需要访问这些目录的权限:",
	"libraryDirPromptGrant": "授权",

	"libraryEditPlaylist": "编辑播放列表",
	"libraryEditPlaylistName": "编辑播放列表名称",
	"libraryEmpty": "您的媒体库是空的",
	"libraryImportTracks": "导入曲目",
	"libraryItemRemovedFromLibrary": "项目已从媒体库中移除",
	"libraryItemsRemovedFromLibrary": "项目已从媒体库中移除",
	"libraryNewPlaylist": "新播放列表",

	"libraryNoResults": "未找到结果",
	"libraryNoResultsExplanation": "尝试搜索其他内容",
	"libraryOpenApplicationMenu": "打开应用菜单",
	"libraryOpenSortMenu": "打开排序菜单",
	"libraryPlaylistCreated": "已创建播放列表 \"{playlistName}\"",
	"libraryPlaylistFieldName": "播放列表名称",
	"libraryPlaylistName": "播放列表名称",
	"libraryPlaylistRemoved": "播放列表已移除",
	"libraryPlaylistsUpdated": "播放列表已更新",
	"libraryPlaylistUpdated": "播放列表已更新",
	"libraryRemove": "移除",
	"libraryRemoveFromLibrary": "从媒体库中移除",
	"librarySave": "保存",

	"librarySearch": "搜索",
	"librarySelectSomethingToBeShown": "从列表中选择要在此处显示的内容",
	"librarySplitViewDisable": "禁用分屏视图布局",
	"librarySplitViewEnable": "启用分屏视图布局",
	"libraryStartByAdding": "首先添加一些音乐",
	"libraryToggleSortOrder": "切换排序方式",
	"libraryTrackRemovedFromPlaylist": "曲目已从播放列表中移除",

	"libraryTrackRemoveFromPlaylist": "从播放列表中移除",
	"libraryTracksCount": "{count} 首曲目",
	"libraryViewDetails": "查看详情",
	"more": "更多",
	"moreOptions": "更多选项",
	"name": "名称",
	"noItemsToDisplay": "没有要显示的项目",
	"pause": "暂停",
	"play": "播放",

	"player": "播放器",
	"playerAddToQueue": "添加到队列",
	"playerAudioErrorLoadError": "无法加载\"{name}\"的音频",
	"playerAudioErrorNotFound": "未找到\"{name}\"的音频文件。该文件可能已被移动或删除。",
	"playerAudioErrorPermissionDenied": "没有权限加载\"{name}\"的音频。请授予浏览器权限后重试。",
	"playerClearHistory": "清除历史记录",
	"playerClearQueue": "清空队列",
	"playerDecreaseVolume": "降低音量",
	"playerDisableRepeat": "禁用重复播放",
	"playerDisableShuffle": "禁用随机播放",
	"playerEnableRepeat": "启用重复播放",
	"playerEnableRepeatOne": "启用单曲循环",
	"playerEnableShuffle": "启用随机播放",
	"playerHistory": "历史记录",
	"playerHistoryEmpty": "您还没有播放任何内容",
	"playerIncreaseVolume": "增加音量",
	"playerOpenFullPlayer": "打开完整播放器",
	"playerOpenHistory": "打开历史记录",
	"playerOpenQueue": "打开队列",
	"playerPause": "暂停",
	"playerPlay": "播放",
	"playerPlayNextTrack": "播放下一首曲目",
	"playerPlayPreviousTrack": "播放上一首曲目",
	"playerQueueEmpty": "您的队列是空的",
	"playerQueuePlaySomething": "在此播放一些内容",
	"playerRemoveFromHistory": "从历史记录中移除",
	"playerRemoveFromQueue": "从队列中移除",
	"playlist": "播放列表",
	"playlists": "播放列表",
	"queue": "队列",
	"reload": "重新加载",
	"replace": "替换",
	"replaceDirectoryExplanation": "您要添加的目录 {newDir} 是 {existingDirs} 的父目录,而 {existingDirs} 已经存在于您的媒体库中。\n 对您媒体库中现有的曲目不会进行任何更改。",

	"replaceDirectoryQ": "替换目录?",
	"selectAll": "全选",
	"selectedCount": "已选择 {count}",
	"settingPickColorFromArtwork": "自动从当前播放歌曲的专辑封面中选取颜色",
	"settings": "设置",
	"settingsAbout": "关于",

	"settingsAddDirectory": "添加目录",

	"settingsAllDataLocal": "所有数据都保存在您的设备上",
	"settingsAppearance": "外观",
	"settingsApplicationTheme": "应用主题",
	"settingsColorPick": "选取颜色",
	"settingsColorReset": "重置",
	"settingsDbOperationInProgress": "数据库操作正在进行中...",
	"settingsDirectories": "目录",
	"settingsDirectoriesTracksCount": "{count} 首曲目",
	"settingsDirectoryRemoved": "目录已移除",
	"settingsDirRemove": "移除",
	"settingsDirRescan": "重新扫描",
	"settingsDisplayVolumeSlider": "在播放器内显示音量滑块",
	"settingsGrantDirectoryAccess": "您需要允许应用访问该目录,通过浏览器权限,以便它可以扫描其内容",
	"settingsImportTracks": "导入曲目",
	"settingsInstallAppDesktop": "桌面版",
	"settingsInstallAppExplanation": "将 Snae 播放器添加到您的 {device} 以获得更沉浸式的体验",
	"settingsInstallAppHomeAction": "安装",
	"settingsInstallAppHomeScreen": "主屏幕",
	"settingsLanguage": "语言",
	"settingsMissingFs1": "您的浏览器不支持所需的 ",
	"settingsMissingFs2": "文件系统功能,",
	"settingsMissingFs3": " 为了使此应用正常工作,",
	"settingsMissingFs4": "每个音乐文件都必须复制并保存在应用存储中,",
	"settingsMissingFs5": " 这可能会占用您设备的大量磁盘空间。",
	"settingsMotion": "动画",
	"settingsMotionAuto": "自动",
	"settingsMotionNormal": "正常",
	"settingsMotionReduced": "减少",
	"settingsPlaybackSpeed": "播放速度",
	"settingsPlaybackSpeedReset": "重置速度",
	"settingsPreparingForScan": "准备扫描",
	"settingsPreservePitch": "保持音高",
	"settingsPreservePitchInfo": "在更改播放速度时,保持人声和乐器的原始音高不变。",
	"settingsPrimaryColor": "应用主色",
	"settingsScanInProgress": "正在扫描曲目。{current}/{total}",
	"settingsScanNewOrUpdatedTracks": "找到 {newTracks} 首新的或更新的曲目",
	"settingsScanNoNewTracks": "未找到新的曲目",
	"settingsThemeAuto": "自动",
	"settingsThemeDark": "深色",
	"settingsThemeLight": "浅色",
	"settingsTracksInAppStorageTooltip": "这包含存储在应用存储中的曲目和/或您从 Snae Player v1 迁移的数据",
	"settingsTracksInsideAppMemory": "应用存储中的曲目",

	"shuffle": "随机播放",
	"successfullyRemovedTracks": "成功移除 {count} 首曲目",
	"track": "曲目",
	"trackAddToFavorites": "添加到收藏夹",

	"trackPlay": "播放 {name}",
	"trackRemoveFromFavorites": "从收藏夹中移除",
	"tracks": "曲目",
	"trackViewAlbum": "查看专辑",
	"trackViewArtist": "查看艺术家",
	"understood": "明白",
	"unknown": "未知",
	"validationMaxLength": "最多允许 {max} 个字符",
	"validationMinLength": "至少需要 {min} 个字符",

	"validationRequired": "必填字段",
	"year": "年份"
}


================================================
FILE: messages/zh-TW.json
================================================
{
	"$schema": "https://inlang.com/schema/inlang-message-format",

	"about": "關於",
	"aboutHomepage": "首頁",
	"aboutJoinDiscord": "加入我們的 Discord",
	"aboutPrivacy": "隱私",
	"aboutSourceCode": "原始碼",

	"album": "專輯",
	"albums": "專輯",

	"appName": "Snae 播放器",
	"appNameShort": "Snae",
	"appUpdateAvailable": "應用程式有可用更新",

	"artist": "藝術家",
	"artists": "藝術家",
	"cancel": "取消",
	"created": "建立時間",
	"description": "描述",
	"directoryIsIncludedInParent": "「{newDir}」是「{existingDir}」的子目錄,而「{existingDir}」已經在您的媒體庫中。您無需再次新增。",
	"dismiss": "忽略",
	"duration": "時長",
	"equalizerClose": "關閉",
	"equalizerOpenEqualizer": "開啟等化器",
	"equalizerPresetAcoustic": "原聲",
	"equalizerPresetBassBoost": "低音增強",
	"equalizerPresetClassical": "古典",
	"equalizerPresetElectronic": "電子",
	"equalizerPresetFlat": "平直",
	"equalizerPresetJazz": "爵士",
	"equalizerPresetPop": "流行",
	"equalizerPresetRock": "搖滾",
	"equalizerPresetTrebleBoost": "高音增強",
	"equalizerReset": "重設",
	"equalizerStatusEnabled": "已啟用",
	"equalizerTitle": "等化器",

	"errorPageDoesNotExist": "看起來此頁面不存在。",
	"errorUnexpected": "發生了一個意外錯誤。",
	"favorites": "收藏夾",
	"foundAnIssue": "發現問題?",
	"goBack": "返回",
	"goHome": "回到首頁",
	"library": "媒體庫",
	"libraryAddToPlaylist": "新增到播放列表",
	"libraryApplicationMenu": "應用程式選單",
	"libraryCancel": "取消",
	"libraryConfirmRemoveMultipleTitle": "您確定要移除這 {count} 項嗎?",
	"libraryConfirmRemoveTitle": "您確定要移除「{name}」嗎?",
	"libraryCreate": "建立",
	"libraryCreateNewPlaylist": "建立新播放列表",
	"libraryDirPromptBrowserPermission": "需要瀏覽器權限",
	"libraryDirPromptExplanation": "要播放音樂,應用程式需要存取這些目錄的權限:",
	"libraryDirPromptGrant": "授權",

	"libraryEditPlaylist": "編輯播放列表",
	"libraryEditPlaylistName": "編輯播放列表名稱",
	"libraryEmpty": "您的媒體庫是空的",
	"libraryImportTracks": "匯入曲目",
	"libraryItemRemovedFromLibrary": "項目已從媒體庫中移除",
	"libraryItemsRemovedFromLibrary": "項目已從媒體庫中移除",
	"libraryNewPlaylist": "新播放列表",

	"libraryNoResults": "未找到結果",
	"libraryNoResultsExplanation": "嘗試搜尋其他內容",
	"libraryOpenApplicationMenu": "開啟應用程式選單",
	"libraryOpenSortMenu": "開啟排序選單",
	"libraryPlaylistCreated": "已建立播放列表「{playlistName}」",
	"libraryPlaylistFieldName": "播放列表名稱",
	"libraryPlaylistName": "播放列表名稱",
	"libraryPlaylistRemoved": "播放列表已移除",
	"libraryPlaylistsUpdated": "播放列表已更新",
	"libraryPlaylistUpdated": "播放列表已更新",
	"libraryRemove": "移除",
	"libraryRemoveFromLibrary": "從媒體庫中移除",
	"librarySave": "儲存",

	"librarySearch": "搜尋",
	"librarySelectSomethingToBeShown": "從列表中選擇要在此處顯示的內容",
	"librarySplitViewDisable": "停用分屏檢視佈局",
	"librarySplitViewEnable": "啟用分屏檢視佈局",
	"libraryStartByAdding": "首先新增一些音樂",
	"libraryToggleSortOrder": "切換排序方式",
	"libraryTrackRemovedFromPlaylist": "曲目已從播放列表中移除",

	"libraryTrackRemoveFromPlaylist": "從播放列表中移除",
	"libraryTracksCount": "{count} 首曲目",
	"libraryViewDetails": "檢視詳細資訊",
	"more": "更多",
	"moreOptions": "更多選項",
	"name": "名稱",
	"noItemsToDisplay": "沒有要顯示的項目",
	"pause": "暫停",
	"play": "播放",

	"player": "播放器",
	"playerAddToQueue": "新增到佇列",
	"playerAudioErrorLoadError": "無法載入\"{name}\"的音訊",
	"playerAudioErrorNotFound": "找不到\"{name}\"的音訊檔案。該檔案可能已被移動或刪除。",
	"playerAudioErrorPermissionDenied": "沒有權限載入\"{name}\"的音訊。請授予瀏覽器權限後再試一次。",
	"playerClearHistory": "清除歷史記錄",
	"playerClearQueue": "清空佇列",
	"playerDecreaseVolume": "降低音量",
	"playerDisableRepeat": "停用重複播放",
	"playerDisableShuffle": "停用隨機播放",
	"playerEnableRepeat": "啟用重複播放",
	"playerEnableRepeatOne": "啟用單曲循環",
	"playerEnableShuffle": "啟用隨機播放",
	"playerHistory": "歷史記錄",
	"playerHistoryEmpty": "您的播放歷史記錄為空",
	"playerIncreaseVolume": "增加音量",
	"playerOpenFullPlayer": "開啟完整播放器",
	"playerOpenHistory": "開啟歷史記錄",
	"playerOpenQueue": "開啟佇列",
	"playerPause": "暫停",
	"playerPlay": "播放",
	"playerPlayNextTrack": "播放下一首曲目",
	"playerPlayPreviousTrack": "播放上一首曲目",
	"playerQueueEmpty": "您的佇列是空的",
	"playerQueuePlaySomething": "在此播放一些內容",
	"playerRemoveFromHistory": "從歷史記錄中移除",
	"playerRemoveFromQueue": "從佇列中移除",
	"playlist": "播放列表",
	"playlists": "播放列表",
	"queue": "佇列",
	"reload": "重新載入",
	"replace": "取代",
	"replaceDirectoryExplanation": "您要新增的目錄 {newDir} 是 {existingDirs} 的父目錄,而 {existingDirs} 已經存在於您的媒體庫中。\n 對您媒體庫中現有的曲目不會進行任何變更。",

	"replaceDirectoryQ": "取代目錄?",
	"selectAll": "全選",
	"selectedCount": "已選取 {count}",
	"settingPickColorFromArtwork": "自動從目前播放歌曲的專輯封面中選取顏色",
	"settings": "設定",
	"settingsAbout": "關於",

	"settingsAddDirectory": "新增目錄",

	"settingsAllDataLocal": "所有資料都保存在您的裝置上",
	"settingsAppearance": "外觀",
	"settingsApplicationTheme": "應用程式主題",
	"settingsColorPick": "選取顏色",
	"settingsColorReset": "重設",
	"settingsDbOperationInProgress": "資料庫操作正在進行中...",
	"settingsDirectories": "目錄",
	"settingsDirectoriesTracksCount": "{count} 首曲目",
	"settingsDirectoryRemoved": "目錄已移除",
	"settingsDirRemove": "移除",
	"settingsDirRescan": "重新掃描",
	"settingsDisplayVolumeSlider": "在播放器內顯示音量滑桿",
	"settingsGrantDirectoryAccess": "您需要允許應用程式存取該目錄,透過瀏覽器權限,以便它可以掃描其內容",
	"settingsImportTracks": "匯入曲目",
	"settingsInstallAppDesktop": "桌面版",
	"settingsInstallAppExplanation": "將 Snae 播放器新增到您的 {device} 以獲得更沉浸式的體驗",
	"settingsInstallAppHomeAction": "安裝",
	"settingsInstallAppHomeScreen": "主畫面",
	"settingsLanguage": "語言",
	"settingsMissingFs1": "您的瀏覽器不支援所需的 ",
	"settingsMissingFs2": "檔案系統功能,",
	"settingsMissingFs3": " 為了使此應用程式正常工作,",
	"settingsMissingFs4": "每個音樂檔案都必須複製並保存在應用程式儲存空間中,",
	"settingsMissingFs5": " 這可能會佔用您裝置的大量磁碟空間。",
	"settingsMotion": "動畫",
	"settingsMotionAuto": "自動",
	"settingsMotionNormal": "正常",
	"settingsMotionReduced": "減少",
	"settingsPlaybackSpeed": "播放速度",
	"settingsPlaybackSpeedReset": "重設速度",
	"settingsPreparingForScan": "準備掃描",
	"settingsPreservePitch": "保持音高",
	"settingsPreservePitchInfo": "在變更播放速度時,保持人聲與樂器的原始音高不變。",
	"settingsPrimaryColor": "應用程式主色",
	"settingsScanInProgress": "正在掃描曲目。{current}/{total}",
	"settingsScanNewOrUpdatedTracks": "找到 {newTracks} 首新的或更新的曲目",
	"settingsScanNoNewTracks": "未找到新的曲目",
	"settingsThemeAuto": "自動",
	"settingsThemeDark": "深色",
	"settingsThemeLight": "淺色",
	"settingsTracksInAppStorageTooltip": "這包含儲存在應用程式儲存空間中的曲目和/或您從 Snae Player v1 遷移的資料",
	"settingsTracksInsideAppMemory": "應用程式儲存空間中的曲目",

	"shuffle": "隨機播放",
	"successfullyRemovedTracks": "成功移除 {count} 首曲目",
	"track": "曲目",
	"trackAddToFavorites": "新增到收藏夾",

	"trackPlay": "播放 {name}",
	"trackRemoveFromFavorites": "從收藏夾中移除",
	"tracks": "曲目",
	"trackViewAlbum": "檢視專輯",
	"trackViewArtist": "檢視藝術家",
	"understood": "明白",
	"unknown": "未知",
	"validationMaxLength": "最多允許 {max} 個字元",
	"validationMinLength": "至少需要 {min} 個字元",

	"validationRequired": "必填欄位",
	"year": "年份"
}


================================================
FILE: netlify.toml
================================================
[build]
publish = "build/"
command = "pnpm run build"

[build.environment]
NODE_VERSION = "24.15.0"

# V1 version of the app used different service worker file name
[[redirects]]
from = "/sw.js"
to = "/service-worker.js"
status = 200
force = true

[[redirects]]
from = "/*"
to = "/200.html"
status = 200

[[headers]]
for = "/*"
[headers.values]
Referrer-Policy = "strict-origin-when-cross-origin"
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
Content-Security-Policy = "frame-ancestors 'none'"
Cross-Origin-Resource-Policy = "same-origin"
Cross-Origin-Embedder-Policy = "require-corp"

[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json; charset=UTF-8"


================================================
FILE: package.json
================================================
{
	"name": "local-music-pwa-next",
	"version": "0.0.1",
	"private": true,
	"type": "module",
	"sideEffects": false,
	"scripts": {
		"prepare": "svelte-kit sync",
		"dev": "vite dev",
		"build": "vite build",
		"preview": "vite preview",
		"type-check": "svelte-kit sync && svelte-check",
		"i18n-check": "node scripts/check-translations.ts",
		"biome-check": "biome check .",
		"biome-fix": "biome check . --write",
		"prettier-check": "prettier --check ./src",
		"prettier-fix": "prettier --write ./src",
		"compile-i18n": "paraglide-js compile --project ./project.inlang",
		"test": "vitest run",
		"gen-color-theme": "node scripts/gen-color-theme.ts",
		"knip": "knip"
	},
	"devDependencies": {
		"@biomejs/biome": "2.4.14",
		"@inlang/paraglide-js": "2.18.0",
		"@resvg/resvg-js": "^2.6.2",
		"@sveltejs/adapter-static": "^3.0.10",
		"@sveltejs/kit": "^2.59.0",
		"@tailwindcss/vite": "^4.2.4",
		"@types/node": "^24.12.2",
		"@types/wicg-file-system-access": "^2023.10.7",
		"fake-indexeddb": "^6.2.5",
		"happy-dom": "^20.9.0",
		"image-size": "^2.0.2",
		"knip": "^6.11.0",
		"prettier": "^3.8.3",
		"prettier-plugin-svelte": "^3.5.1",
		"prettier-plugin-tailwindcss": "^0.8.0",
		"svelte": "5.55.5",
		"svelte-check": "^4.4.7",
		"tailwindcss": "^4.2.4",
		"typescript": "^6.0.3",
		"unplugin-auto-import": "^21.0.0",
		"vite": "8.0.10",
		"vitest": "^4.1.5"
	},
	"dependencies": {
		"@material/material-color-utilities": "^0.4.0",
		"@tanstack/virtual-core": "^3.14.0",
		"idb": "^8.0.3",
		"music-metadata": "^11.12.3",
		"tiny-invariant": "^1.3.3",
		"weak-lru-cache": "^1.2.2"
	},
	"engines": {
		"node": "24.15.0"
	},
	"packageManager": "pnpm@11.0.3"
}


================================================
FILE: patches/@material__material-color-utilities.patch
================================================
diff --git a/dynamiccolor/color_spec_2025.js b/dynamiccolor/color_spec_2025.js
index 8bef961c7c6127c028b98ee3305270be5247a0c2..271597946422c58b91a54c1e1a748d30350ac015 100644
--- a/dynamiccolor/color_spec_2025.js
+++ b/dynamiccolor/color_spec_2025.js
@@ -18,7 +18,7 @@ import { Hct } from '../hct/hct.js';
 import * as math from '../utils/math_utils.js';
 import { ColorSpecDelegateImpl2021 } from './color_spec_2021.js';
 import { ContrastCurve } from './contrast_curve.js';
-import { DynamicColor, extendSpecVersion } from './dynamic_color';
+import { DynamicColor, extendSpecVersion } from './dynamic_color.js';
 import { ToneDeltaPair } from './tone_delta_pair.js';
 import { Variant } from './variant.js';
 /**
diff --git a/index.d.ts b/index.d.ts
index 0618f70eb5221eeed3cf7a2e5724940716490aa3..1c5b5d9af414afea61489898ce45e2c93f882e08 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js';
 export * from './dynamiccolor/variant.js';
 export * from './hct/cam16.js';
 export * from './hct/hct.js';
+export * from './hct/hct_solver.js';
 export * from './hct/viewing_conditions.js';
 export * from './palettes/core_palette.js';
 export * from './palettes/tonal_palette.js';
diff --git a/index.js b/index.js
index 0fede2e15730083a6c54ebd8cb0c36a1f3109486..ae708d01b37849c30f84dc628884f696e6527a3a 100644
--- a/index.js
+++ b/index.js
@@ -23,6 +23,7 @@ export * from './dynamiccolor/material_dynamic_colors.js';
 export * from './dynamiccolor/variant.js';
 export * from './hct/cam16.js';
 export * from './hct/hct.js';
+export * from './hct/hct_solver.js';
 export * from './hct/viewing_conditions.js';
 export * from './palettes/core_palette.js';
 export * from './palettes/tonal_palette.js';
diff --git a/package.json b/package.json
index 30ca4ac79453a16cf6a124eb126575af26146a69..fbde7a1241338fb8bc250a24b383b64893b73dfb 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
   "publishConfig": {
     "access": "public"
   },
+  "sideEffects": false,
   "description": "Algorithms and utilities that power the Material Design 3 (M3) color system, including choosing theme colors from images and creating tones of colors; all in a new color space.",
   "keywords": [
     "material",
diff --git a/scheme/scheme_content.js b/scheme/scheme_content.js
index e06c67bc68883b4a5210dc4241544ed79dec905c..29d62f7514f53d30402ee2c002801d90a2938e2f 100644
--- a/scheme/scheme_content.js
+++ b/scheme/scheme_content.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A scheme that places the source color in `Scheme.primaryContainer`.
diff --git a/scheme/scheme_expressive.js b/scheme/scheme_expressive.js
index 43d05c6a9989566f2f300e8bf01fa4f1a8e120f0..baf7ca348f18ddc82e8d1f4df26161b70fbb578d 100644
--- a/scheme/scheme_expressive.js
+++ b/scheme/scheme_expressive.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A Dynamic Color theme that is intentionally detached from the source color.
diff --git a/scheme/scheme_fidelity.js b/scheme/scheme_fidelity.js
index a7461cdd4b49a49e642cd1060fba95ea1b6c7a1e..602d1eec140103f4f9501d9a84238eece61416f9 100644
--- a/scheme/scheme_fidelity.js
+++ b/scheme/scheme_fidelity.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A scheme that places the source color in `Scheme.primaryContainer`.
diff --git a/scheme/scheme_fruit_salad.js b/scheme/scheme_fruit_salad.js
index 87443afa8bb09d9c6f67be12fde55b7db9b9f37a..aeaff555801de637d3b8005eed30718d88c362df 100644
--- a/scheme/scheme_fruit_salad.js
+++ b/scheme/scheme_fruit_salad.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A playful theme - the source color's hue does not appear in the theme.
diff --git a/scheme/scheme_monochrome.js b/scheme/scheme_monochrome.js
index 30ad712ad104e397788ff3d8e11ea28ca00c0533..d18d656c4f98bbd4a1340feeaeff390f9a8b83fc 100644
--- a/scheme/scheme_monochrome.js
+++ b/scheme/scheme_monochrome.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /** A Dynamic Color theme that is grayscale. */
 export class SchemeMonochrome extends DynamicScheme {
diff --git a/scheme/scheme_neutral.js b/scheme/scheme_neutral.js
index 0d03a5e0f0200feb471860b48d5243dd5da5429d..e4cffaaa9d66bd056fded64bfb0ce43341789c66 100644
--- a/scheme/scheme_neutral.js
+++ b/scheme/scheme_neutral.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /** A Dynamic Color theme that is near grayscale. */
 export class SchemeNeutral extends DynamicScheme {
diff --git a/scheme/scheme_rainbow.js b/scheme/scheme_rainbow.js
index 65e6d3fd934ed07a2e6efdf6c552c306873d26e2..f80be9ae6da0d181c18a064ca4c645835abf9f86 100644
--- a/scheme/scheme_rainbow.js
+++ b/scheme/scheme_rainbow.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A playful theme - the source color's hue does not appear in the theme.
diff --git a/scheme/scheme_tonal_spot.js b/scheme/scheme_tonal_spot.js
index 6c506c3c23279e5842757b991cd066ef266fceea..c48f498c41e0cde98f34c3d7394a75ef1bf764cd 100644
--- a/scheme/scheme_tonal_spot.js
+++ b/scheme/scheme_tonal_spot.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A Dynamic Color theme with low to medium colorfulness and a Tertiary
diff --git a/scheme/scheme_vibrant.js b/scheme/scheme_vibrant.js
index cba1172e7d7ccae62d03e635c38063153bc5a61a..3a37230ad4b589c7d3071dbb093335eab0d71f4c 100644
--- a/scheme/scheme_vibrant.js
+++ b/scheme/scheme_vibrant.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { DynamicScheme } from '../dynamiccolor/dynamic_scheme';
+import { DynamicScheme } from '../dynamiccolor/dynamic_scheme.js';
 import { Variant } from '../dynamiccolor/variant.js';
 /**
  * A Dynamic Color theme that maxes out colorfulness at each position in the


================================================
FILE: pnpm-workspace.yaml
================================================
engineStrict: true

allowBuilds:
  '@biomejs/biome': false
  '@tailwindcss/oxide': false

patchedDependencies:
  '@material/material-color-utilities': 'patches/@material__material-color-utilities.patch'


================================================
FILE: project.inlang/settings.json
================================================
{
	"$schema": "https://inlang.com/schema/project-settings",
	"baseLocale": "en",
	"locales": ["en", "lt", "de", "fr", "zh-CN", "zh-TW"],
	"modules": [
		"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js",
		"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
	],
	"plugin.inlang.messageFormat": {
		"pathPattern": "./messages/{languageTag}.json"
	}
}


================================================
FILE: scripts/check-translations.ts
================================================
import projectSettings from '../project.inlang/settings.json' with { type: 'json' }

type Messages = Record<string, string>

interface LocaleIssues {
	missingKeys: string[]
	paramMismatches: string[]
}

interface LocaleReport {
	locale: string
	issues: LocaleIssues
}

interface BaseMessageWithParams {
	value: string
	params: string[]
}

const extractParams = (value: string): string[] => {
	const paramsRegex = /{(\w+)}/g
	const params: string[] = []

	const matches = value.matchAll(paramsRegex)
	for (const match of matches) {
		params.push(match[1])
	}

	return params
}

const getMessages = async (locale: string): Promise<Messages> => {
	const module = (await import(`../messages/${locale}.json`, { with: { type: 'json' } })) as {
		default: Messages
	}

	delete module.default.$schema

	return module.default
}

const checkLocale = async (locale: string, baseMessagesMap: Map<string, BaseMessageWithParams>) => {
	const messages = await getMessages(locale)
	const issues: LocaleIssues = {
		missingKeys: [],
		paramMismatches: [],
	}

	for (const [key, baseData] of baseMessagesMap) {
		if (key in messages) {
			const localeParams = extractParams(messages[key])

			const missingParams = baseData.params.filter((param) => !localeParams.includes(param))
			const extraParams = localeParams.filter((param) => !baseData.params.includes(param))

			if (missingParams.length > 0) {
				issues.paramMismatches.push(key)
			} else if (extraParams.length > 0) {
				issues.paramMismatches.push(key)
			}
		} else {
			issues.missingKeys.push(key)
		}
	}

	return {
		locale,
		issues,
	}
}

const printReport = (reports: LocaleReport[]) => {
	let hasAnyIssues = false

	for (const report of reports) {
		const { locale, issues } = report

		if (issues.paramMismatches.length === 0 && issues.missingKeys.length === 0) {
			console.info(`✅ Locale "${locale}" has no issues`)
		} else {
			hasAnyIssues = true
		}

		if (issues.missingKeys.length > 0) {
			console.info(`❌ "${locale}" missing keys:`)
			console.info(issues.missingKeys)
		}

		if (issues.paramMismatches.length > 0) {
			console.info(`❌ "${locale}" has param mismatches in keys:`)
			console.info(issues.paramMismatches)
		}
	}

	if (hasAnyIssues) {
		process.exit(1)
	} else {
		process.exit(0)
	}
}

const baseMessages = await getMessages(projectSettings.baseLocale)
const baseMessagesMap = new Map<string, BaseMessageWithParams>()

for (const [key, value] of Object.entries(baseMessages)) {
	baseMessagesMap.set(key, {
		value,
		params: extractParams(value),
	})
}

const reports: LocaleReport[] = []

for (const locale of projectSettings.locales) {
	if (locale !== projectSettings.baseLocale) {
		const report = await checkLocale(locale, baseMessagesMap)
		reports.push(report)
	}
}

printReport(reports)


================================================
FILE: scripts/gen-color-theme.ts
================================================
import { writeFileSync } from 'node:fs'
import {
	argbFromHex,
	// biome-ignore lint/style/noRestrictedImports: Used for static theme generation
} from '@material/material-color-utilities'
import { getThemePaletteRgbEntries } from '../src/lib/theme.ts'

const defaultColorSeed = '#cc9724'
const outputFile = `${import.meta.dirname}/../src/theme-colors.css`

const argb = argbFromHex(defaultColorSeed)

const tokensLightEntries = getThemePaletteRgbEntries(argb, false)
const tokensDark = Object.fromEntries(getThemePaletteRgbEntries(argb, true))

const variables = tokensLightEntries
	.map(([name, lightValue]) => `--color-${name}: light-dark(${lightValue}, ${tokensDark[name]});`)
	.join('\n	')

const content = `/* This file is auto generated, do not edit manually. */
@theme {
	--color-*: initial;
	--color-transparent: transparent;
	--color-current: currentColor;
	${variables}
}
`

writeFileSync(outputFile, content, {
	encoding: 'utf-8',
})


================================================
FILE: src/ambient.d.ts
================================================
declare module '*?as=metadata' {
	const metadata: {
		src: string
		width: number
		height: number
	}

	export default metadata
}


================================================
FILE: src/app.css
================================================
@import 'tailwindcss';
@import './theme-colors.css';

/* We don't use these classes */
@source not inline('container');

/* latin */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: swap;
	src: url('/fonts/Heebo.latin.woff2') format('woff2');
	unicode-range:
		U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
		U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: swap;
	src: url('/fonts/Heebo.latin-ext.woff2') format('woff2');
	unicode-range:
		U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
		U+A720-A7FF;
}
/* cyrillic */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: swap;
	src: url('/fonts/Heebo.cyrillic.woff2') format('woff2');
	unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* cyrillic-ext */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: swap;
	src: url('/fonts/Heebo.cyrillic-ext.woff2') format('woff2');
	unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* greek */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: swap;
	src: url('/fonts/Heebo.greek.woff2') format('woff2');
	unicode-range: U+0370-03FF;
}
/* greek-ext */
@font-face {
	font-family: 'Heebo';
	font-style: normal;
	font-display: fallback;
	src: url('/fonts/Heebo.greek-ext.woff2') format('woff2');
	unicode-range: U+1F00-1FFF;
}

@font-face {
	font-family: 'App CJK Fallback';
	src:
		local('Noto Sans SC'), local('Noto Sans CJK SC'), local('Noto Sans TC'),
		local('Noto Sans CJK TC'), local('Noto Sans HK'), local('Noto Sans CJK HK'),
		local('PingFang SC'), local('PingFang TC'), local('Microsoft YaHei'),
		local('Microsoft JhengHei');
	font-style: normal;
	font-weight: 400 900;
	font-display: swap;
	unicode-range:
		U+3000-303F, /* CJK punctuation */ U+3400-4DBF, /* CJK Ext A */ U+4E00-9FFF,
		/* CJK Unified Ideographs */ U+F900-FAFF, /* CJK Compatibility Ideographs */ U+FF00-FFEF; /* Half/Fullwidth */
}

@theme {
	--breakpoint-xs: 24rem;
	--breakpoint-xss: 20rem;

	--ease-*: initial;
	--ease-standard: cubic-bezier(0.2, 0, 0, 1);
	--ease-outgoing40: cubic-bezier(0.4, 0, 1, 1);
	--ease-incoming80: cubic-bezier(0, 0, 0.2, 1);
	--ease-incoming80outgoing40: cubic-bezier(0.4, 0, 0.2, 1);

	--font-sans: 'Heebo', 'App CJK Fallback', system-ui, 'Noto Color Emoji', sans-serif;
	--text-*: initial;
}

html {
	background-color: var(--color-surface);
	color: var(--color-onSurface);
	font-family: var(--font-sans);
	font-optical-sizing: auto;
	color-scheme: light;
	width: 100%;
	overflow-y: scroll;
	overflow-x: hidden;
	touch-action: manipulation;
	-webkit-touch-callout: none; /* Disable the iOS popup when long-press on a link */
	/* Chrome implementation with fixed elements is very buggy */
	/* scrollbar-gutter: stable; */
	scroll-padding-top: var(--app-header-height);
	scroll-padding-bottom: var(--bottom-overlay-height);
	--app-header-height: --spacing(16);
	--app-max-content-width: --spacing(400);
	--mktg-content-max-width: --spacing(300);
}

html.dark {
	color-scheme: dark;
}

html,
body {
	min-height: 100dvh;
}

body {
	-webkit-tap-highlight-color: transparent;
	display: flex;
	flex-direction: column;
	/* Needed for Safari */
	-webkit-user-select: none;
	user-select: none;
	@apply text-body-md;
}

#app {
	display: flex;
	flex-direction: column;
	flex-grow: 1;
}

source {
	display: none;
}

@keyframes fade-in {
	from {
		opacity: 0;
	}
	to {
		opacity: 1;
	}
}

@keyframes fade-out {
	from {
		opacity: 1;
	}
	to {
		opacity: 0;
	}
}

@layer base {
	* {
		scrollbar-width: thin;
		scrollbar-color: --alpha(var(--color-secondary) / 30%) transparent;
	}

	:where(:focus) {
		outline: none;
	}

	:where(:focus-visible) {
		outline: --spacing(0.5) solid var(--color-onSurface);
		outline-offset: --spacing(0.5);
	}

	strong {
		font-weight: 600;
	}
}

@keyframes tooltip-fade-in {
	from {
		opacity: 0;
	}
	to {
		opacity: 1;
	}
}

.tooltip {
	position: fixed;
	position-area: bottom;
	position-try-fallbacks: top, left, right;
	margin: 8px;
	animation: tooltip-fade-in 0.3s ease-out;
}

@utility interactable {
	overflow: hidden;
	appearance: none;
	border: none;
	outline-width: 0;
	text-decoration: none;
	cursor: pointer;
	display: flex;
	align-items: center;
	z-index: 0;
	position: relative;
	transition: outline-width 150ms linear;

	&::after {
		display: none;
		content: '';
		position: absolute;
		height: 100%;
		width: 100%;
		left: 0;
		top: 0;
		background: currentColor;
		z-index: -1;
		pointer-events: none;
		opacity: 0;
		transition:
			opacity 0.2s linear,
			display 0.2s allow-discrete;
	}

	@media (any-hover: hover) {
		&:hover::after {
			display: block;
			opacity: 0.08;
		}

		&[disabled]::after {
			display: none;
		}
	}

	&:is(:focus-visible),
	&:hover:focus-visible {
		outline-width: --spacing(0.5);
	}

	&:focus-visible::after {
		display: block;
		opacity: 0.12;
	}

	@starting-style {
		&::after {
			opacity: 0 !important;
		}
	}
}

@utility flip-x {
	transform: scaleX(-1);
}

@utility flip-y {
	transform: scaleY(-1);
}

@utility text-headline-lg {
	font-size: --spacing(8);
	line-height: --spacing(10);
	letter-spacing: 0;
	font-weight: 700;
}

@utility text-headline-md {
	font-size: --spacing(7);
	line-height: --spacing(9);
	letter-spacing: 0;
	font-weight: 400;
}

@utility text-headline-sm {
	font-size: --spacing(6);
	line-height: --spacing(8);
	letter-spacing: 0;
	font-weight: 400;
}

@utility text-title-lg {
	font-size: --spacing(5.5);
	line-height: --spacing(7);
	letter-spacing: 0;
	font-weight: 500;
}

@utility text-title-md {
	font-size: --spacing(4);
	line-height: --spacing(6);
	letter-spacing: 0.15px;
	font-weight: 500;
}

@utility text-title-sm {
	font-size: --spacing(3.5);
	line-height: --spacing(5);
	letter-spacing: 0.1px;
	font-weight: 500;
}

@utility text-label-lg {
	font-size: --spacing(3.5);
	line-height: --spacing(5);
	letter-spacing: 0.1px;
	font-weight: 500;
}

@utility text-label-md {
	font-size: 12px;
	line-height: --spacing(4);
	letter-spacing: 0.5px;
	font-weight: 500;
}

@utility text-label-sm {
	font-size: 11px;
	line-height: --spacing(4);
	letter-spacing: 0.5px;
	font-weight: 500;
}

@utility text-body-lg {
	font-size: --spacing(4);
	line-height: --spacing(6);
	letter-spacing: 0.15px;
	font-weight: 400;
}

@utility text-body-md {
	font-size: --spacing(3.5);
	line-height: --spacing(5);
	letter-spacing: 0.25px;
	font-weight: 400;
}

@utility text-body-sm {
	font-size: --spacing(3);
	line-height: --spacing(4);
	letter-spacing: 0.4px;
	font-weight: 400;
}

@utility view-name-* {
	view-transition-name: --value([*]);
}

@utility scrollbar-gutter-stable {
	scrollbar-gutter: stable;
}

@utility stack-in-grid {
	grid-area: 1 / 1;
}

@custom-variant active-view-player {
	html:active-view-transition-type(player) & {
		@slot;
	}
}

@custom-variant active-view-regular {
	html:active-view-transition-type(regular) & {
		@slot;
	}
}

@layer components {
	.link {
		text-decoration: underline;
	}

	.mktg-content-width {
		width: 100%;
		max-width: var(--mktg-content-max-width);
		padding-left: --spacing(6);
		padding-right: --spacing(6);
		margin-left: auto;
		margin-right: auto;
		align-items: center;
		display: flex;
		flex-direction: column;
	}

	.mktg-content-width-using-grid {
		display: grid;
		grid-template-columns: minmax(0, var(--mktg-content-max-width));
		justify-content: center;
	}

	.card {
		background-color: var(--color-surfaceContainer);
		display: flex;
		flex-direction: column;
		border-radius: --spacing(2);
		color: var(--color-onSurface);
	}

	.virtual-item {
		position: absolute !important;
		contain: strict;
		will-change: transform;
	}

	.ripple {
		width: --spacing(1);
		height: --spacing(1);
		position: absolute;
		border-radius: 50%;
		opacity: 0.2;
		background-color: currentColor;
		animation-fill-mode: both;
		contain: strict;
		will-change: transform, opacity;
		pointer-events: none;
	}
}

/* Hide Netlify preview bar */
div[data-netlify-deploy-id] {
	display: none;
}


================================================
FILE: src/app.d.ts
================================================
import type { Snippet as SnippetInternal } from 'svelte'
import type { ClassValue as ClassValueInternal } from 'svelte/elements'

declare module '$env/static/public' {
	const PUBLIC_FALLBACK_PAGE: string
	const PUBLIC_GOAT_COUNTER_URL: string
}

declare global {
	namespace App {
		// interface Error {}
		// interface Locals {}
		interface PageData {
			noPlayerOverlay?: boolean
			htmlOverflow?: 'auto' | 'default'
		}
		// interface Platform {}
	}

	// Not using unplugin auto import because because when used getting error:
	// Exported variable 'Foo' has or is using private name 'ParentChild'
	type ClassValue = ClassValueInternal
	type Snippet<Parameters extends unknown[] = []> = SnippetInternal<Parameters>

	interface Navigator {
		// Optional because Safari and Firefox don't support it
		userAgentData?: {
			mobile: boolean
			platform: 'macOS' | 'Windows' | (string & {})
			brands: {
				brand: string
				version: string
			}[]
		}
	}

	/**
	 * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler
	 * before a user is prompted to "install" a web site to a home screen on mobile.
	 */
	interface BeforeInstallPromptEvent extends Event {
		/**
		 * Returns an array of DOMString items containing the platforms on which the event was dispatched.
		 * This is provided for user agents that want to present a choice of versions to the user such as,
		 * for example, "web" or "play" which would allow the user to chose between a web version or
		 * an Android version.
		 */
		readonly platforms: string[]

		/**
		 * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed".
		 */
		readonly userChoice: Promise<{
			outcome: 'accepted' | 'dismissed'
			platform: string
		}>

		/**
		 * Allows a developer to show the install prompt at a time of their own choosing.
		 * This method returns a Promise.
		 */
		prompt: () => Promise<void>
	}

	interface WindowEventMap {
		beforeinstallprompt: BeforeInstallPromptEvent
	}

	interface GoatCounter {
		count: (data: { path: string; title?: string; event?: boolean }) => void
	}

	interface Window {
		/** Analytics. If ad blocker blocks it this will be undefined */
		goatcounter?: GoatCounter
	}

	// All modern browsers use PointerEvent instead of MouseEvent for
	// click, dblclick, and contextmenu. Since we can't change global
	// type easily we just add missing properties to MouseEvent to make it compatible with PointerEvent.
	interface MouseEvent {
		pointerType: 'mouse' | 'pen' | 'touch'
	}
}


================================================
FILE: src/app.html
================================================
<!doctype html>
<html lang="en" class="dark" data-sveltekit-preload-data="false">
	<head>
		<meta charset="utf-8">

		<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
		%snae.theme-color-meta%

		<title>Snae Player</title>

		<meta name="description" content="%snae.description%">

		<link rel="icon" href="/icons/raster-16.png" sizes="16x16" type="image/png">
		<link rel="icon" href="/icons/raster-32.png" sizes="32x32" type="image/png">
		<link rel="icon" href="/icons/raster-48.png" sizes="48x48" type="image/png">
		<link rel="icon" href="/icons/raster-128.png" sizes="128x128" type="image/png">
		<link rel="icon" href="/icons/raster-192.png" sizes="192x192" type="image/png">

		<link rel="icon" href="/icons/responsive.svg" type="image/svg+xml">

		<link
			rel="preload"
			href="/fonts/Heebo.latin.woff2"
			as="font"
			type="font/woff2"
			crossorigin="anonymous"
		>
		%sveltekit.head%

		<link rel="manifest" href="/manifest.webmanifest">
	</head>

	<body>
		<noscript>Please enable Javascript in order to use this app.</noscript>
		<div id="unsupported-browser" hidden class="m-auto rounded-2xl bg-[dimgray] p-5 text-[white]">
			<div>
				This browser does not support required technologies for this app to function correctly.
			</div>
			<div>Please upgrade your browser to the latest version of:</div>
			<a class="link text-primary" href="https://www.mozilla.org/en-US/firefox/new/">Firefox</a>
			<a class="link text-primary" href="https://www.microsoft.com/en-us/windows/microsoft-edge">
				Edge
			</a>
			<a class="link text-primary" href="https://www.google.com/chrome/">Chrome</a>
			<a class="link text-primary" href="https://www.apple.com/safari/">Safari</a>
		</div>
		%snae.svg-icons-paths%

		<div id="app">%sveltekit.body%</div>

		<script
			src="https://gc.zgo.at/count.js"
			data-goatcounter="%snae.goat-counter-url%/count"
			crossorigin="anonymous"
			async
			data-goatcounter-settings='{"no_onload": true}'
		></script>

		<script defer src="/supported-browser-check.js"></script>
	</body>
</html>


================================================
FILE: src/hooks.server.ts
================================================
import type { Handle } from '@sveltejs/kit'
import { APP_DESCRIPTION_EN } from '$lib/app-metadata.ts'
import { ICON_PATHS } from '$lib/components/icon/icon-paths.server.ts'
import { PUBLIC_FALLBACK_PAGE, PUBLIC_GOAT_COUNTER_URL } from '$env/static/public'
import { THEME_PALLETTE_DARK, THEME_PALLETTE_LIGHT } from './server/theme-colors.ts'

const getThemeColorMeta = (color: string | undefined, theme: 'dark' | 'light') =>
	`<meta name="theme-color" content="${color}" media="(prefers-color-scheme: ${theme})" />`

const replaceThemeColorMeta = (html: string) =>
	html.replace(
		'%snae.theme-color-meta%',
		`
		${getThemeColorMeta(THEME_PALLETTE_LIGHT.surface, 'light')}
		${getThemeColorMeta(THEME_PALLETTE_DARK.surface, 'dark')}
		`,
	)

const getSvgSymbol = (name: string, path: string) =>
	`<symbol id="system-icon-${name}">
		<path d="${path}" />
	</symbol>`

const replaceSvgIconPaths = (html: string) => {
	const icons = Object.entries(ICON_PATHS)

	// Instead of keeping the icons paths in the client js bundle, we can inline them in the html
	// making loading tiny bit faster
	return html.replace(
		'%snae.svg-icons-paths%',
		`
		<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display: none;">
			<defs>
				${icons.map(([name, path]) => getSvgSymbol(name, path)).join('')}
			</defs>
		</svg>`,
	)
}

const replaceGoatCounterUrl = (html: string) =>
	html.replaceAll('%snae.goat-counter-url%', PUBLIC_GOAT_COUNTER_URL)

const replaceDescription = (html: string) => html.replace('%snae.description%', APP_DESCRIPTION_EN)

const transformPageChunk = ({ html }: { html: string }) => {
	html = replaceSvgIconPaths(html)
	html = replaceThemeColorMeta(html)
	html = replaceGoatCounterUrl(html)
	html = replaceDescription(html)

	return html
}

// This will only run in dev/preview or build and not in production
// since we are using the static adapter
export const handle: Handle = async ({ event, resolve }) => {
	// Adding this so service-worker can properly cache the 200.html
	if (event.url.pathname === PUBLIC_FALLBACK_PAGE) {
		const response = await resolve(event, { transformPageChunk })

		return new Response(response.body, {
			status: 200,
			headers: response.headers,
		})
	}

	return resolve(event, { transformPageChunk })
}


================================================
FILE: src/lib/app-metadata.ts
================================================
export const APP_NAME_EN = 'Snae Player'
export const APP_NAME_SHORT_EN = 'Snae'
export const APP_DESCRIPTION_EN =
	'Play your local music in the browser with playlists, equalizer, playback speed, and offline listening. No uploads, no sign-up.'


================================================
FILE: src/lib/attachments/ripple.ts
================================================
import type { Attachment } from 'svelte/attachments'
import { on } from 'svelte/events'
import { assign } from '$lib/helpers/utils/assign.ts'
import { animateEmpty } from '../helpers/animations.ts'

const FADE_DURATION = 180
const SCALE_DURATION = 400

const createRippleSpan = () => {
	if (import.meta.env.SSR) {
		return null as unknown as HTMLSpanElement
	}

	const span = document.createElement('span')
	span.className = 'ripple'

	return span
}

const rippleSpan = createRippleSpan()
const activeRipples = new Map<HTMLSpanElement, boolean>()

/** @public */
export const getActiveRipplesCount = (): number => activeRipples.size

const markForOrExitRipple = (ripple: HTMLSpanElement) => {
	const canExit = activeRipples.get(ripple)

	if (canExit) {
		const fadeAni = ripple.animate(
			{ opacity: 0 },
			{
				duration: FADE_DURATION,
				easing: 'linear',
			},
		)
		fadeAni.finished.then(() => {
			activeRipples.delete(ripple)
			ripple.remove()
		})
	} else {
		activeRipples.set(ripple, true)
	}
}

const onExitHandler = () => {
	if (activeRipples.size === 0) {
		return
	}

	for (const ripple of activeRipples.keys()) {
		markForOrExitRipple(ripple)
	}
}

if (!import.meta.env.SSR) {
	document.addEventListener('pointercancel', onExitHandler, { passive: true })
	document.addEventListener('pointerup', onExitHandler, { passive: true })
}

const onPointerDownHandler = (e: PointerEvent) => {
	// Only respond to main click events.
	if (e.button !== 0) {
		return
	}

	const node = e.currentTarget as HTMLElement

	if (node.hasAttribute('disabled')) {
		return
	}

	const rect = node.getBoundingClientRect()

	const ripple = rippleSpan.cloneNode() as HTMLSpanElement

	// Use small value and scale it up to the right size,
	// because that way less GPU memory is used
	// when container is very big.
	const realDiameter = 4
	const realRadius = realDiameter / 2

	const posX = e.clientX - rect.left
	const posY = e.clientY - rect.top

	assign(ripple.style, {
		top: `${posY - realRadius}px`,
		left: `${posX - realRadius}px`,
	})

	activeRipples.set(ripple, false)
	node.appendChild(ripple)

	// Find absolute distance from center of the click
	// to the edge of the container.
	const distanceToCX = Math.max(posX, rect.width - posX)
	const distanceToCY = Math.max(posY, rect.height - posY)
	const distanceC = Math.max(distanceToCX, distanceToCY)

	// Place square inside the container so it fills all available space,
	// then draw circle around it. This is basic idea of this calculation.
	const squareSide = distanceC * 2
	const diameter = Math.sqrt(squareSide ** 2 * 2)

	const scaleValue = diameter / realDiameter

	ripple.animate(
		{ transform: ['scale(0)', `scale(${scaleValue})`] },
		{
			duration: SCALE_DURATION,
			easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
			fill: 'both',
		},
	)

	animateEmpty(ripple, SCALE_DURATION - FADE_DURATION).finished.then(() =>
		markForOrExitRipple(ripple),
	)
}

export interface RippleOptions {
	stopPropagation?: boolean
}

export const ripple =
	(options: RippleOptions = {}): Attachment<HTMLElement> =>
	(node) => {
		const cleanup = on(node, 'pointerdown', (e) => {
			if (options?.stopPropagation) {
				e.stopPropagation()
			}

			onPointerDownHandler(e)
		})
		return cleanup
	}


================================================
FILE: src/lib/attachments/tooltip.ts
================================================
import type { Attachment } from 'svelte/attachments'
import { on } from 'svelte/events'
import { browser } from '$app/environment'

let tooltipTemplate: HTMLDivElement | null = null
const cloneTooltipTemplate = () => {
	if (tooltipTemplate === null) {
		tooltipTemplate = document.createElement('div')
		tooltipTemplate.setAttribute('role', 'tooltip')
		tooltipTemplate.className =
			'tooltip bg-inverseSurface max-w-80 flex items-center m-0 text-body-sm min-h-6 text-inverseOnSurface px-2 py-0.5 rounded-sm'
		tooltipTemplate.popover = 'manual'
	}

	return tooltipTemplate.cloneNode() as HTMLDivElement
}

const supportsCssAnchor = browser && CSS.supports('anchor-name', '--a')
let tooltipCounter = 0

export const tooltip = (message: string | undefined): Attachment<HTMLElement> => {
	tooltipCounter += 1
	const anchorName = `--tooltip-${tooltipCounter}`

	return (target) => {
		if (!message || import.meta.env.SSR || !supportsCssAnchor) {
			return
		}

		target.setAttribute('title', message)

		let tooltipElement: HTMLElement | null = null
		let timeoutId: number | null = null
		const controller = new AbortController()
		const { signal } = controller

		const clearTooltipTimeout = () => {
			if (timeoutId) {
				window.clearTimeout(timeoutId)
				timeoutId = null
			}
		}

		const showTooltip = () => {
			if (tooltipElement || !message) {
				return
			}

			// Remove attribute to prevent default browser tooltip
			target.removeAttribute('title')
			target.style.anchorName = anchorName

			tooltipElement = cloneTooltipTemplate()
			tooltipElement.textContent = message
			tooltipElement.style.positionAnchor = anchorName

			document.body.appendChild(tooltipElement)
			tooltipElement.showPopover()
		}

		const scheduleShowTooltip = () => {
			if (tooltipElement) {
				return
			}

			timeoutId = window.setTimeout(showTooltip, 300)
		}

		const hideTooltip = () => {
			clearTooltipTimeout()
			// Restore the title attribute
			if (message) {
				target.setAttribute('title', message)
			}

			target.style.removeProperty('anchor-name')
			if (tooltipElement) {
				tooltipElement.remove()
				tooltipElement = null
			}
		}

		on(target, 'pointerenter', scheduleShowTooltip, { signal })
		on(
			target,
			'focusin',
			() => {
				if (target.matches(':focus-visible')) {
					scheduleShowTooltip()
				}
			},
			{ signal },
		)

		on(target, 'pointerleave', hideTooltip, { signal })
		// Makes so tooltip is hidden just before view transitions starts
		on(target, 'pointerup', hideTooltip, { signal })
		on(target, 'focusout', hideTooltip, { signal })
		// Needed for Safari
		on(target, 'touchend', hideTooltip, { signal })

		const cleanup = () => {
			controller.abort()
			hideTooltip()
		}

		return cleanup
	}
}


================================================
FILE: src/lib/components/AlbumsListContainer.svelte
================================================
<script lang="ts">
	import { formatArtists, formatNameOrUnknown } from '$lib/helpers/utils/text.ts'
	import LibraryGridListContainer from './library-grid/LibraryGridListContainer.svelte'

	interface Props {
		items: readonly number[]
	}

	const { items }: Props = $props()
</script>

<LibraryGridListContainer type="albums" {items}>
	{#snippet item(album)}
		<div class="truncate text-onSurface">
			{formatNameOrUnknown(album.name)}
		</div>
		<div class="truncate">
			{formatArtists(album.artists)}
		</div>
	{/snippet}
</LibraryGridListContainer>


================================================
FILE: src/lib/components/ArtistListContainer.svelte
================================================
<script lang="ts">
	import LibraryGridListContainer from '$lib/components/library-grid/LibraryGridListContainer.svelte'
	import { formatNameOrUnknown } from '$lib/helpers/utils/text'

	interface Props {
		items: number[]
	}

	const { items }: Props = $props()
</script>

<LibraryGridListContainer type="artists" {items}>
	{#snippet item(artist)}
		<div class="truncate text-onSurface">
			{formatNameOrUnknown(artist.name)}
		</div>
	{/snippet}
</LibraryGridListContainer>


================================================
FILE: src/lib/components/Artwork.svelte
================================================
<script lang="ts">
	import type { IconType } from './icon/Icon.svelte'
	import Icon from './icon/Icon.svelte'

	interface Props {
		src: string | undefined
		class?: ClassValue
		alt?: string
		fallbackIcon?: IconType | false
		noFallbackBg?: boolean
		children?: Snippet
	}

	const {
		src,
		fallbackIcon = 'musicNote',
		noFallbackBg,
		class: className,
		alt,
		children,
	}: Props = $props()

	let error = $state(false)

	$effect(() => {
		void src

		untrack(() => {
			error = false
		})
	})
</script>

<div
	class={[
		'flex aspect-square overflow-hidden ring-1 ring-surfaceContainerHigh contain-strict',
		!noFallbackBg && 'bg-surfaceContainerHighest',
		className,
	]}
>
	{#if src && !error}
		<!-- biome-ignore lint/a11y/useAltText: false positive, alt exists -->
		<img
			{src}
			{alt}
			loading="eager"
			class="size-full object-cover"
			draggable="false"
			onerror={() => {
				error = true
			}}
			onload={() => {
				error = false
			}}
		/>
	{:else if fallbackIcon !== false}
		<Icon type={fallbackIcon} class="m-auto size-2/3" />
	{/if}

	{#if children}
		{@render children()}
	{/if}
</div>


================================================
FILE: src/lib/components/BackButton.svelte
================================================
<script lang="ts">
	import { goto } from '$app/navigation'
	import IconButton from './IconButton.svelte'

	interface Props {
		class?: ClassValue
	}

	const { class: className }: Props = $props()

	const canGoBack = () => {
		if (window.navigation !== undefined) {
			return window.navigation.canGoBack
		}

		// This will not be a reliable check, but better than nothing
		return window.history.length > 1
	}

	const handleBackClick = () => {
		if (canGoBack()) {
			window.history.back()
		} else {
			void goto('/library/tracks')
		}
	}
</script>

<IconButton tooltip={m.goBack()} icon="backArrow" class={className} onclick={handleBackClick} />


================================================
FILE: src/lib/components/Button.svelte
================================================
<script module lang="ts">
	import { ripple } from '../attachments/ripple.ts'
	import { tooltip } from '../attachments/tooltip.ts'

	export type AllowedButtonElement = 'button' | 'a'
	export type ButtonKind = 'filled' | 'toned' | 'outlined' | 'flat' | 'blank'

	export type ButtonHref<As extends AllowedButtonElement> = As extends 'a' ? string : never

	export interface ButtonProps<As extends AllowedButtonElement> {
		as?: As
		kind?: ButtonKind
		type?: 'button' | 'submit' | 'reset'
		target?: string
		disabled?: boolean
		href?: ButtonHref<As>
		class?: ClassValue
		tabindex?: number
		ariaLabel?: string
		tooltip?: string
		children?: Snippet
		onclick?: (event: MouseEvent) => void
		onpointerdown?: (event: PointerEvent) => void
	}
</script>

<script lang="ts" generics="As extends AllowedButtonElement = 'button'">
	const {
		as = 'button' as As,
		kind = 'filled',
		disabled = false,
		// svelte-ignore state_referenced_locally possible false positive?
		href = (as === 'a' ? '' : undefined) as ButtonHref<As>,
		type = 'button',
		children,
		ariaLabel,
		tooltip: tooltipMessage,
		...restProps
	}: ButtonProps<As> = $props()

	const KIND_CLASS_MAP = {
		filled: 'filled-button',
		toned: 'tonal-button',
		outlined: 'outlined-button',
		flat: 'flat-button',
		blank: '',
	} as const
</script>

<svelte:element
	this={(disabled ? 'button' : as) as AllowedButtonElement}
	{@attach ripple({ stopPropagation: true })}
	{@attach tooltip(tooltipMessage)}
	{...restProps}
	{type}
	aria-label={ariaLabel}
	{href}
	disabled={disabled === true ? true : undefined}
	class={[
		'interactable',
		KIND_CLASS_MAP[kind],
		kind !== 'blank' &&
			'base-button flex h-10 items-center justify-center gap-2 rounded-3xl px-6 text-label-lg transition-[outline-width] duration-150',
		restProps.class,
	]}
>
	{#if children}
		{@render children()}
	{/if}
</svelte:element>

<style lang="postcss">
	@reference '../../app.css';

	.filled-button {
		background: var(--color-primary);
		color: var(--color-onPrimary);
	}

	.tonal-button {
		background: var(--color-secondaryContainer);
		color: var(--color-onSecondaryContainer);
	}

	.outlined-button {
		color: var(--color-primary);
		border: 1px solid var(--color-outline);
	}

	.flat-button {
		color: var(--color-primary);
		padding-left: --spacing(3);
		padding-right: --spacing(3);
	}

	.base-button[disabled] {
		cursor: default;
		background-color: --alpha(var(--color-onSurface) / 12%);
		border-color: --alpha(var(--color-onSurface) / 38%);
		color: --alpha(var(--color-onSurface) / 38%);
	}
</style>


================================================
FILE: src/lib/components/FavoriteButton.svelte
================================================
<script lang="ts">
	import IconButton from '$lib/components/IconButton.svelte'
	import { toggleFavoriteTrack } from '$lib/library/playlists-actions'

	interface FavoriteButtonProps {
		trackId: number
		favorite: boolean
		tabindex?: number
		class?: ClassValue
	}

	const { trackId, favorite, tabindex, class: className }: FavoriteButtonProps = $props()
	const clickHandler = async (e: MouseEvent) => {
		e.stopPropagation()

		const success = await toggleFavoriteTrack(favorite, trackId)
		if (!success) {
			return
		}

		const icon = (e.target as HTMLElement)?.querySelector('svg')
		if (!icon) {
			return
		}

		icon.animate(
			{
				transform: ['scale(1)', 'scale(0.6)', 'scale(1)'],
			},
			{
				duration: 400,
				easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
			},
		)
	}
</script>

<IconButton
	{tabindex}
	class={className}
	icon={favorite ? 'favorite' : 'favoriteOutline'}
	tooltip={favorite ? m.trackRemoveFromFavorites() : m.trackAddToFavorites()}
	onclick={clickHandler}
/>


================================================
FILE: src/lib/components/Header.svelte
================================================
<script lang="ts" module>
	import { browser } from '$app/environment'
	export interface HeaderProps {
		children?: Snippet
		title?: string
		noBackButton?: boolean
		/** @default 'fixed' */
		mode?: 'fixed' | 'sticky'
		class?: (isElevated: boolean) => ClassValue
	}
</script>

<script lang="ts">
	import BackButton from './BackButton.svelte'

	const { children, title, noBackButton, mode = 'fixed', class: className }: HeaderProps = $props()

	const isFixed = $derived(mode === 'fixed')

	let scrollThresholdEl = $state<HTMLDivElement>()
	let isScrolled = $state(false)

	if (browser) {
		const io = new IntersectionObserver(
			([entry]) => {
				isScrolled = !entry?.isIntersecting
			},
			{ threshold: 0 },
		)

		$effect(() => {
			if (scrollThresholdEl) {
				io.observe(scrollThresholdEl)
			}

			return () => {
				io.disconnect()
			}
		})
	}
</script>

<div bind:this={scrollThresholdEl} class="h-0 w-full" inert></div>

{#if isFixed}
	<div class="h-(--app-header-height) shrink-0" aria-hidden="true"></div>
{/if}

<header
	class={[
		'ease-in-out inset-x-0 top-0 z-10 flex h-(--app-header-height) shrink-0 transition-[background-color] duration-200',
		isScrolled && 'bg-surfaceContainerHigh',
		isFixed ? 'fixed' : 'sticky',
		className?.(isScrolled),
	]}
>
	<div
		class="mx-auto flex w-full max-w-(--app-max-content-width) items-center justify-end gap-2 pr-2 pl-6"
	>
		{#if !noBackButton}
			<BackButton class={[!title && 'mr-auto']} />
		{/if}

		{#if title}
			<div class="mr-auto text-title-lg">{title}</div>
		{/if}

		{@render children?.()}
	</div>
</header>


================================================
FILE: src/lib/components/IconButton.svelte
================================================
<script lang="ts" module>
	import Button, { type AllowedButtonElement, type ButtonProps } from './Button.svelte'
	import Icon, { type IconType } from './icon/Icon.svelte'

	interface IconButtonProps<As extends AllowedButtonElement> extends ButtonProps<As> {
		tooltip: string
		icon?: IconType
		children?: Snippet
	}
</script>

<script lang="ts" generics="As extends AllowedButtonElement = 'button'">
	const { icon, children, ...rest }: IconButtonProps<As> = $props()
</script>

<Button
	{...rest}
	kind="blank"
	class={[
		'flex size-11 shrink-0 items-center justify-center rounded-full',
		rest.class,
		rest.disabled && 'opacity-54',
	]}
>
	{#if children}
		{@render children()}
	{:else if icon}
		<Icon type={icon} />
	{/if}
</Button>


================================================
FILE: src/lib/components/ListDetailsLayout.svelte
================================================
<script lang="ts" module>
	import ScrollContainer from './ScrollContainer.svelte'

	export type LayoutMode = 'both' | 'list' | 'details'
</script>

<script lang="ts">
	interface Props {
		id?: string
		mode: LayoutMode
		list: Snippet<[LayoutMode]>
		details: Snippet<[LayoutMode]>
		class?: ClassValue
		noPlayerOverlayPadding?: boolean
		noListStableGutter?: boolean
	}

	const {
		id,
		mode,
		list,
		details,
		class: className,
		noListStableGutter,
		noPlayerOverlayPadding,
	}: Props = $props()

	let listOffsetWidth = $state(0)
	let isBothMode = $derived(mode === 'both')
</script>

<div {id} class={['flex! flex-col!', className]}>
	<div class="flex h-full grow">
		{#if isBothMode}
			<ScrollContainer
				bind:offsetWidth={listOffsetWidth}
				class={[
					'fixed top-0 flex max-h-dvh min-h-full shrink-0 flex-col overflow-y-auto overscroll-contain',
					!noPlayerOverlayPadding && 'pb-[calc(var(--bottom-overlay-height)+16px)]',
					!noListStableGutter && 'scrollbar-gutter-stable',
				]}
			>
				{@render list(mode)}
			</ScrollContainer>
		{/if}

		<div
			class={[
				'flex w-full grow flex-col',
				!noPlayerOverlayPadding && 'pb-[calc(var(--bottom-overlay-height)+16px)]',
			]}
			style={isBothMode ? `padding-left: ${listOffsetWidth}px;` : undefined}
		>
			{#if isBothMode || mode === 'details'}
				{@render details(mode)}
			{:else if mode === 'list'}
				{@render list(mode)}
			{/if}
		</div>
	</div>
</div>


================================================
FILE: src/lib/components/ListItem.svelte
================================================
<script lang="ts" module>
	import { ripple } from '$lib/attachments/ripple.ts'
</script>

<script lang="ts">
	interface Props {
		style?: string
		class?: ClassValue
		ariaLabel: string
		ariaRowIndex: number
		tabindex: number
		children: Snippet
		onclick?: (e: KeyboardEvent | MouseEvent) => void
		onpointerenter?: (e: PointerEvent) => void
		oncontextmenu?: (e: MouseEvent) => void
	}

	const {
		children,
		class: className,
		style,
		ariaLabel,
		ariaRowIndex,
		tabindex = 0,
		onclick,
		oncontextmenu,
		onpointerenter,
	}: Props = $props()

	const clickHandler = (e: KeyboardEvent | MouseEvent) => onclick?.(e)
</script>

<div
	{@attach ripple()}
	{style}
	{tabindex}
	class={[
		className,
		'flex cursor-pointer items-center overflow-hidden rounded-lg pr-2 pl-4 -outline-offset-2 contain-content hover:bg-onSurface/10',
	]}
	role="row"
	aria-label={ariaLabel}
	aria-rowindex={ariaRowIndex}
	onclick={clickHandler}
	{onpointerenter}
	onkeydown={(e) => {
		if (e.key === 'Enter') {
			clickHandler(e)
		}
	}}
	{oncontextmenu}
>
	{@render children()}
</div>


================================================
FILE: src/lib/components/MenuButton.svelte
================================================
<script lang="ts" module>
	import IconButton from './IconButton.svelte'
	import type { IconType } from './icon/icon-paths.server.ts'
	import type { MenuAlignment, MenuItem } from './menu/types.ts'

	export type { MenuItem }

	export type ListMenuFn = () => MenuItem[]
</script>

<script lang="ts">
	interface Props {
		class?: ClassValue
		ariaLabel?: string
		tooltip?: string
		tabindex?: number
		alignment?: MenuAlignment
		width?: number
		icon?: IconType
		menuItems?: (() => MenuItem[]) | MenuItem[]
	}

	const {
		class: className,
		ariaLabel,
		tooltip = m.moreOptions(),
		tabindex = 0,
		menuItems,
		alignment = { horizontal: 'right', vertical: 'top' },
		width,
		icon = 'moreVertical',
	}: Props = $props()

	const menu = useMenu()
</script>

{#if menuItems}
	<IconButton
		{ariaLabel}
		{tabindex}
		{icon}
		{tooltip}
		class={className}
		onclick={(e) => {
			e.stopPropagation()

			menu.showFromEvent(e, typeof menuItems === 'function' ? menuItems() : menuItems, {
				anchor: true,
				width,
				preferredAlignment: alignment,
			})
		}}
	/>
{/if}


================================================
FILE: src/lib/components/PlayerOverlay.svelte
================================================
<script lang="ts">
	import { formatArtists, getItemLanguage } from '$lib/helpers/utils/text.ts'
	import Button from './Button.svelte'
	import Icon from './icon/Icon.svelte'
	import PlayerFavoriteButton from './player/buttons/PlayerFavoriteButton.svelte'
	import PlayNextButton from './player/buttons/PlayNextButton.svelte'
	import PlayToggleButton from './player/buttons/PlayToggleButton.svelte'
	import MainControls from './player/MainControls.svelte'
	import PlayerArtwork from './player/PlayerArtwork.svelte'
	import Timeline from './player/Timeline.svelte'
	import VolumeSlider from './player/VolumeSlider.svelte'

	const { class: className }: { class?: ClassValue } = $props()

	const mainStore = useMainStore()
	const player = usePlayer()

	const track = $derived(player.activeTrack)
</script>

<div
	id="mini-player"
	class={[
		'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',
		className,
	]}
>
	<div class="flex size-full flex-col items-center justify-between gap-4 sm:px-4 sm:pt-2 sm:pb-4">
		<Timeline class="max-sm:hidden" />
		<div class="flex h-min w-full grow grid-cols-[1fr_max-content_1fr] items-center sm:grid">
			<div class="flex grow items-center">
				<Button
					as="a"
					href="/player"
					kind="blank"
					tooltip={m.playerOpenFullPlayer()}
					class="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"
				>
					<div
						class="relative -z-1 size-11 shrink-0 overflow-hidden rounded-lg bg-onSecondary active-view-player:view-name-[pl-artwork]"
					>
						{#if track}
							<PlayerArtwork class="size-full" />
						{/if}

						<Icon
							type="chevronUp"
							class={[
								'absolute inset-0 m-auto shrink-0 active-view-player:view-name-[pl-chevron-up]',
								track &&
									'scale-0 rounded-full bg-tertiary text-onTertiary transition-[transform,opacity] duration-200 [.group:hover_&]:scale-100',
							]}
						/>
					</div>

					{#if track}
						<div class="mr-1 ml-4 grid min-w-0" lang={getItemLanguage(track.language)}>
							<div class="truncate text-body-md">
								{track.name}
							</div>
							<div class="truncate text-body-sm">{formatArtists(track.artists)}</div>
						</div>
					{/if}
				</Button>

				<PlayerFavoriteButton />
			</div>

			<div class="ml-auto flex gap-2 pr-2 sm:hidden">
				<PlayToggleButton />

				<PlayNextButton class="max-xss:hidden" />
			</div>

			<MainControls class="max-sm:hidden" />

			<div class="ml-auto flex items-center gap-2 pr-2 max-sm:hidden">
				{#if mainStore.volumeSliderEnabled}
					<VolumeSlider />
				{/if}
			</div>
		</div>
	</div>
</div>

<style lang="postcss">
	@reference '../../app.css';

	.controls {
		grid-template-columns: 1fr max-content 1fr;
	}

	::view-transition-old(pl-chevron-up) {
		display: none;
	}

	@keyframes -global-view-pl-chevron-up-fade-in {
		from {
			opacity: 0;
			transform: scale(0);
		}
	}

	::view-transition-new(pl-chevron-up) {
		animation: view-pl-chevron-up-fade-in 125ms 225ms linear backwards;
	}
</style>


================================================
FILE: src/lib/components/ScrollContainer.svelte
================================================
<script lang="ts" module>
	import { getContext, setContext } from 'svelte'

	type ScrollTargetElement = Element | Window | null

	const contextKey = Symbol('scroll-target')

	export const useScrollTarget = () => {
		const nodeGetter = getContext<() => ScrollTargetElement>(contextKey)

		return {
			get current(): Element | Window {
				const node = nodeGetter?.()

				return node ?? window
			},
		}
	}
</script>

<script lang="ts">
	interface Props {
		class?: ClassValue
		offsetWidth?: number
		children: Snippet
	}

	let { class: className, offsetWidth = $bindable(), children }: Props = $props()

	let scrollTarget = $state<ScrollTargetElement>(null)

	setContext(contextKey, () => scrollTarget)
</script>

<div bind:this={scrollTarget} bind:offsetWidth class={['overscroll-contain', className]}>
	{@render children()}
</div>


================================================
FILE: src/lib/components/Select.svelte
================================================
<script lang="ts" module>
	import { ripple } from '$lib/attachments/ripple.ts'
	import Icon from './icon/Icon.svelte'

	export interface SelectProps<T, Key extends keyof T, LabelKey extends keyof T> {
		items: readonly T[]
		key: Key
		labelKey: LabelKey
		placeholder?: string
		selected?: T[Key]
		class?: ClassValue
	}
</script>

<script lang="ts" generics="T, const Key extends keyof T, const LabelKey extends keyof T">
	let {
		items,
		key,
		labelKey,
		placeholder,
		selected = $bindable(),
		class: className,
	}: SelectProps<T, Key, LabelKey> = $props()

	const selectedItem = $derived(items.find((item) => item[key] === selected))

	const uid = $props.id()
	const anchorName = `--select-anchor-${uid}`

	const popupId = `select-popup-${uid}`
	let popup = $state<HTMLDivElement | null>(null)
	let isOpen = $state(false)
</script>

<button
	{@attach ripple()}
	style={`anchor-name: ${anchorName};`}
	class={[
		'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',
		className,
	]}
	role="combobox"
	aria-controls={popupId}
	aria-owns={popupId}
	aria-expanded={isOpen}
	popovertarget={popupId}
	type="button"
>
	<div class="truncate">
		{#if selectedItem}
			{selectedItem[labelKey]}
		{:else}
			{placeholder}
		{/if}
	</div>

	<Icon type="menuDown" class="ml-auto size-5" />
</button>

<div
	bind:this={popup}
	id={popupId}
	aria-orientation="vertical"
	role="listbox"
	popover="auto"
	style={`position-anchor: ${anchorName};`}
	class="select-popup m-0 hidden flex-col rounded-sm bg-surfaceContainerHighest px-0 py-2 shadow-xl open:flex"
	ontoggle={(e) => {
		isOpen = e.newState === 'open'
	}}
>
	{#each items as item (item[key])}
		<button
			{@attach ripple()}
			role="option"
			aria-selected={item[key] === selected}
			type="button"
			class={[
				'interactable flex h-10 w-full cursor-pointer items-center overflow-hidden px-4 -outline-offset-2',
				item[key] === selected && 'text-primary',
			]}
			onclick={() => {
				selected = item[key]
				popup?.hidePopover()
			}}
		>
			{item[labelKey]}
		</button>
	{/each}
</div>

<style>
	.select-popup {
		position-area: bottom center;
		position-try-fallbacks: flip-block;
		width: anchor-size(width);
		transition-property: opacity, overlay, display;
		transition-duration: 0.2s;
		transition-behavior: allow-discrete;
		opacity: 0;
		&:popover-open {
			opacity: 1;
		}
	}

	@starting-style {
		[popover]:popover-open {
			opacity: 0;
		}
	}
</style>


================================================
FILE: src/lib/components/Separator.svelte
================================================
<script lang="ts">
	interface Props {
		vertical?: boolean
		class?: ClassValue
	}

	const { vertical, class: className }: Props = $props()
</script>

<!-- biome-ignore-start lint/a11y/useAriaPropsForRole: false positive -->
<div
	role="separator"
	aria-orientation={vertical ? 'vertical' : 'horizontal'}
	class={[
		className,
		'shrink-0 self-stretch border-outlineVariant',
		vertical ? 'w-0 border-r' : 'h-0 border-b',
	]}
></div>
<!-- biome-ignore-end lint/a11y/useAriaPropsForRole: false positive -->


================================================
FILE: src/lib/components/Slider.svelte
================================================
<script lang="ts">
	import { clamp } from '$lib/helpers/utils/clamp.ts'

	interface Props {
		min?: number
		max?: number
		step?: number
		value: number
		disabled?: boolean
		vertical?: boolean
		onSeekStart?: () => void
		onSeekEnd?: () => void
	}

	let {
		min = 0,
		max = 100,
		step,
		value = $bindable(0),
		disabled,
		vertical = false,
		onSeekStart,
		onSeekEnd,
	}: Props = $props()

	const progressPercentage = $derived.by(() => {
		const percentage = ((value - min) * 100) / (max - min)
		const percentageSafe = Number.isFinite(percentage) ? percentage : 0

		return percentageSafe
	})

	let trackSize = $state(0)

	const getValueFromPercentage = (percentage: number, rangeMin: number, rangeMax: number) => {
		const newValue = (percentage / 100) * (rangeMax - rangeMin) + rangeMin

		return newValue
	}

	interface TrackBorderOptions {
		trackStart: number
		trackEnd: number
		roundedStart: number
		roundedEnd: number
	}

	const getPercentageFromValue = (value: number, rangeMin: number, rangeMax: number) =>
		((value - rangeMin) / (rangeMax - rangeMin)) * 100

	const getTrackRange = (currentTrackSize: number, options: TrackBorderOptions) => {
		const sizePercentage = getPercentageFromValue(
			currentTrackSize,
			options.trackStart,
			options.trackEnd,
		)

		const borderValue = getValueFromPercentage(
			sizePercentage,
			options.roundedStart,
			options.roundedEnd,
		)

		return clamp(Math.round(borderValue), options.roundedStart, options.roundedEnd)
	}

	const getBarBorder = () => {
		const currentTrackSize = getValueFromPercentage(progressPercentage, 0, trackSize)

		const start = getTrackRange(currentTrackSize, {
			trackStart: 0,
			trackEnd: 36,
			roundedStart: 2,
			roundedEnd: 8,
		})

		const end = getTrackRange(currentTrackSize, {
			trackStart: trackSize,
			trackEnd: trackSize - 36,
			roundedStart: 2,
			roundedEnd: 8,
		})

		if (vertical) {
			return `border-radius: ${end}px ${end}px ${start}px ${start}px;`
		}

		return `border-radius: ${start}px ${end}px ${end}px ${start}px;`
	}

	const getTransform = (calc = '') => {
		if (vertical) {
			return `transform: translateY(calc(-${progressPercentage}% ${calc}));`
		}

		return `transform: translateX(calc(${progressPercentage}% ${calc}));`
	}
</script>

<div
	class={['slider relative flex overflow-hidden select-none', vertical ? 'h-full' : 'w-full']}
	bind:clientWidth={
		null,
		(width: number) => {
			if (!vertical) {
				trackSize = width
			}
		}
	}
	bind:clientHeight={
		null,
		(height: number) => {
			if (vertical) {
				trackSize = height
			}
		}
	}
>
	<input
		type="range"
		bind:value
		{disabled}
		{min}
		{max}
		{step}
		class={[
			'grow appearance-none opacity-0 disabled:cursor-auto',
			vertical ? 'vertical-input h-full w-11' : 'horizontal-input h-11 w-full',
		]}
		style={vertical ? 'writing-mode: vertical-lr; direction: rtl;' : ''}
		onpointerdown={() => {
			onSeekStart?.()
		}}
		onpointerup={() => {
			onSeekEnd?.()
		}}
		ontouchstart={() => {
			onSeekStart?.()
		}}
		ontouchend={() => {
			onSeekEnd?.()
		}}
	/>

	<div
		class={[
			'pointer-events-none absolute',
			vertical
				? 'bottom-0 left-0 mt-2 flex h-(--slider-size) w-full flex-col justify-end'
				: 'top-0 left-0 mr-2 h-full w-(--slider-size)',
		]}
		style={getTransform()}
	>
		<div
			class={[
				'thumb rounded-lg transition-transform',
				vertical ? 'h-1 w-full' : 'h-full w-1',
				disabled ? 'bg-onSurface/38' : 'bg-primary',
			]}
		></div>
	</div>

	<div
		class={[
			'pointer-events-none absolute inset-0 self-end overflow-clip transition-[border-radius] duration-50 contain-strict',
			vertical ? 'mx-auto h-(--slider-size) w-4' : 'my-auto h-4 w-(--slider-size)',
		]}
		style={getBarBorder()}
	>
		<div
			class={[
				'absolute',
				vertical
					? 'rounded-t-0.5 inset-x-0 -bottom-full mx-auto h-full w-4'
					: 'rounded-r-0.5 inset-y-0 -left-full my-auto h-4 w-full',
				disabled ? 'bg-onSurface/38' : 'bg-primary',
			]}
			style={getTransform(vertical ? '+ 6px' : '- 6px')}
		></div>

		<div
			class={[
				'absolute size-full',
				vertical ? 'rounded-b-0.5 bottom-0 left-0' : 'rounded-l-0.5 top-0 left-0',
				disabled ? 'bg-onSurface/12' : 'bg-primary/30',
			]}
			style={getTransform(vertical ? '- 10px' : '+ 10px')}
		></div>
	</div>
</div>

<style lang="postcss">
	@reference '../../app.css';

	.slider {
		--slider-size: calc(100% - --spacing(1));
	}

	.horizontal-input:not(:disabled):is(:active, :focus-visible) ~ div > .thumb {
		transform: scaleX(0.5);
	}
	.vertical-input:not(:disabled):is(:active, :focus-visible) ~ div > .thumb {
		transform: scaleY(0.5);
	}

	input::-webkit-slider-thumb {
		-webkit-appearance: none;
		cursor: pointer;
		background-color: red;
	}

	input:disabled::-webkit-slider-thumb {
		cursor: auto;
	}

	.horizontal-input::-webkit-slider-thumb {
		height: --spacing(11);
		width: --spacing(4);
	}

	.vertical-input::-webkit-slider-thumb {
		height: --spacing(4);
		width: --spacing(11);
	}

	input::-moz-range-thumb {
		cursor: pointer;
	}

	input:disabled::-moz-range-thumb {
		cursor: auto;
	}
</style>


================================================
FILE: src/lib/components/Spinner.svelte
================================================
<script lang="ts">
	// Spinner from https://codepen.io/mrrocks/pen/EiplA

	interface Props {
		class?: ClassValue
	}

	const { class: className }: Props = $props()
</script>

<svg class={['spinner', className]} fill="transparent" width="40" height="40" viewBox="0 0 66 66">
	<circle class="path" cx="33" cy="33" r="30" />
</svg>

<style>
	.spinner {
		--spinner-duration: 1.4s;
		--spinner-offset: 187;
		animation: rotate var(--spinner-duration) linear infinite;
		color: currentcolor;
	}

	.path {
		stroke: currentcolor;
		stroke-width: 6;
		stroke-linecap: round;
		stroke-dasharray: var(--spinner-offset);
		stroke-dashoffset: 0;
		transform-origin: center;
		animation: dash var(--spinner-duration) ease-in-out infinite;
	}

	@keyframes rotate {
		0% {
			transform: rotate(0deg);
		}
		100% {
			transform: rotate(270deg);
		}
	}

	@keyframes dash {
		0% {
			stroke-dashoffset: var(--spinner-offset);
		}
		50% {
			stroke-dashoffset: 46.75;
			transform: rotate(135deg);
		}
		100% {
			stroke-dashoffset: var(--spinner-offset);
			transform: rotate(450deg);
		}
	}
</style>


================================================
FILE: src/lib/components/Switch.svelte
================================================
<script lang="ts">
	interface Props {
		checked: boolean
	}

	let { checked = $bindable(false) }: Props = $props()

	const toggle = () => {
		checked = !checked
	}
</script>

<div
	class={[
		'flex h-8 w-13 shrink-0 cursor-pointer items-center rounded-4xl border-2 outline-offset-2 transition-all duration-150',
		checked ? 'border-transparent bg-primary' : 'border-outline bg-surfaceContainerHigh',
	]}
	tabindex="0"
	role="switch"
	aria-checked={checked}
	onclick={toggle}
	onkeydown={(e) => {
		if (e.key === 'Enter' || e.key === ' ') {
			e.preventDefault()
			toggle()
		}
	}}
>
	<input type="checkbox" bind:checked class="hidden" />
	<div
		class={[
			'ml-1.5 h-4 w-4 rounded-full transition-all duration-150',
			checked ? 'translate-x-5 scale-150 bg-onPrimary' : 'bg-outline',
		]}
	></div>
</div>


================================================
FILE: src/lib/components/Tabs.svelte
================================================
<script lang="ts" module>
	import { ripple } from '$lib/attachments/ripple'

	interface Props<T> {
		selectedIndex: number
		items: readonly T[]
		onchange: (item: T, index: number) => void
		text: Snippet<[T]>
		class?: ClassValue
	}
</script>

<script lang="ts" generics="T">
	const { selectedIndex, items, onchange, text, class: className }: Props<T> = $props()
</script>

<div
	style="grid-template-columns: repeat({items.length}, minmax(0, 1fr))"
	class={['grid gap-1 rounded-full bg-surfaceContainerHighest p-1', className]}
	role="tablist"
>
	<span
		inert
		style="transform: translateX(calc((100% + var(--spacing)) * {selectedIndex}));"
		class="col-1 row-1 rounded-full bg-secondaryContainer transition-transform duration-150"
	></span>
	{#each items as item, index}
		<button
			{@attach ripple()}
			type="button"
			style="grid-area: 1/ {index + 1};"
			class="relative min-w-20 cursor-pointer overflow-clip rounded-full px-4 py-2"
			aria-selected={index === selectedIndex}
			role="tab"
			onclick={() => onchange(item, index)}
		>
			{@render text(item)}
		</button>
	{/each}
</div>


================================================
FILE: src/lib/components/TextField.svelte
================================================
<script lang="ts">
	interface TextFieldProps {
		value?: string
		name: string
		type?: 'text'
		placeholder?: string
		minLength?: number
		maxLength?: number
		required?: boolean
		class?: ClassValue
	}

	let {
		name,
		value = $bindable(''),
		type = 'text',
		placeholder,
		minLength,
		maxLength,
		required,
		class: className,
	}: TextFieldProps = $props()

	const id = $props.id()

	const validationIssue = $derived.by(() => {
		const valueLength = value.length
		if (required && valueLength < 1) {
			return m.validationRequired()
		}

		if (minLength !== undefined && valueLength < minLength) {
			return m.validationMinLength({ min: minLength })
		}

		if (maxLength !== undefined && valueLength > maxLength) {
			return m.validationMaxLength({ max: maxLength })
		}

		return null
	})
</script>

<div class={[className, 'text-field-container']}>
	<div
		class="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"
	>
		<input
			bind:value
			{name}
			{id}
			{type}
			{required}
			class="w-full grow appearance-none border-none bg-transparent px-3.5 outline-none placeholder:text-onSurfaceVariant"
			{placeholder}
			{@attach (input) => {
				input.setCustomValidity(validationIssue ? ' ' : '')
			}}
		/>
	</div>
	<div class="text-field-error mt-1 hidden px-4 text-body-sm text-error">
		{validationIssue ?? ''}
	</div>
</div>

<style>
	.text-field-container:has(input:user-invalid) .text-field-error {
		display: block;
	}
</style>


================================================
FILE: src/lib/components/VirtualContainer.svelte
================================================
<script lang="ts">
	import {
		elementScroll,
		observeElementOffset,
		observeElementRect,
		observeWindowOffset,
		observeWindowRect,
		type Range,
		type VirtualItem,
		type VirtualizerOptions,
		windowScroll,
	} from '@tanstack/virtual-core'
	import { doesElementHasFocus, findFocusedElement } from '$lib/helpers/focus.ts'
	import { createVirtualizerBase } from '$lib/helpers/virtualizer.svelte.ts'
	import { useScrollTarget } from './ScrollContainer.svelte'

	interface Props {
		count: number
		lanes?: number
		size: number
		gap?: number
		forceRenderIndexes?: readonly number[]
		offsetWidth?: number
		key: (index: number) => string | number
		children: Snippet<[VirtualItem]>
	}

	let {
		count,
		lanes = 1,
		gap = 0,
		size: itemSize,
		forceRenderIndexes = [],
		key,
		children,
		offsetWidth = $bindable(0),
	}: Props = $props()

	const scrollTarget = useScrollTarget()

	type VirtualizerTargetOptions<E extends Window | Element> = Pick<
		VirtualizerOptions<E, Element>,
		| 'getScrollElement'
		| 'observeElementRect'
		| 'observeElementOffset'
		| 'scrollToFn'
		| 'initialOffset'
	>

	const scrollTargetOptions = $derived.by(() => {
		const target = scrollTarget.current

		if (target instanceof Window) {
			const options: VirtualizerTargetOptions<Window> = {
				getScrollElement: () => target,
				observeElementRect: observeWindowRect,
				observeElementOffset: observeWindowOffset,
				scrollToFn: windowScroll,
				initialOffset: () => window.scrollY,
			}

			return options
		}

		const options: VirtualizerTargetOptions<Element> = {
			getScrollElement: () => target,
			observeElementRect,
			observeElementOffset,
			scrollToFn: elementScroll,
		}

		return options
	})

	const rangeExtractor = (range: Range) =>
		// We untrack because when focusIndex changes it forces virtualizer deps to change
		// which is not needed here.
		untrack(() => {
			const start = Math.max(range.startIndex - range.overscan, 0)
			const initialEnd = range.endIndex + range.overscan

			const arr = []
			if (focusIndex !== -1 && focusIndex < start) {
				arr.push(focusIndex)
			}

			const end = Math.min(initialEnd, range.count - 1)
			for (let i = start; i <= end; i += 1) {
				arr.push(i)
			}

			if (focusIndex !== -1 && focusIndex > initialEnd) {
				arr.push(focusIndex)
			}

			for (const index of forceRenderIndexes) {
				if (index < 0 || index >= range.count || arr.includes(index)) {
					continue
				}

				arr.push(index)
			}

			return arr
		})

	const getVirtualizerOptions = () => {
		const options: VirtualizerOptions<Window | Element, Element> = {
			// narrowing window/element specific types is difficult so we just cast here
			...(scrollTargetOptions as VirtualizerTargetOptions<Window | Element>),
			count,
			lanes,
			estimateSize: () => itemSize,
			rangeExtractor,
			overscan: 10,
		}

		return options
	}

	const virtualizer = createVirtualizerBase(getVirtualizerOptions)

	let focusIndex = $state(-1)

	let container = $state<HTMLDivElement>()

	const findRow = (index: number) => {
		const el = container?.querySelector(`[aria-rowindex="${index}"]`)
		if (el instanceof HTMLElement) {
			return el
		}

		return null
	}

	const findCurrentFocusedRow = () => {
		const index = container ? Number(findFocusedElement(container)?.ariaRowIndex) : -1

		return Number.isNaN(index) ? -1 : index
	}

	const keydownHandler = (e: KeyboardEvent) => {
		let directionDown: boolean | undefined
		if (e.key === 'ArrowDown') {
			directionDown = true
		} else if (e.key === 'ArrowUp') {
			directionDown = false
		}

		if (directionDown === undefined) {
			return
		}

		e.preventDefault()

		if (container && doesElementHasFocus(container)) {
			virtualizer.scrollToIndex(0, {
				behavior: 'smooth',
			})
			// TODO. Should somehow await for scroll to finish.
			queueMicrotask(() => {
				findRow(0)?.focus()
			})

			return
		}

		const increment = directionDown ? 1 : -1
		const currentIndex = findCurrentFocusedRow()

		const nextIndex = currentIndex + increment
		if (nextIndex >= 0 && nextIndex < count) {
			virtualizer.scrollToIndex(currentIndex, {
				behavior: 'smooth',
			})

			queueMicrotask(() => {
				findRow(nextIndex)?.focus()
			})
		}
	}

	const focusinHandler = () => {
		const index = findCurrentFocusedRow()
		if (index !== -1) {
			focusIndex = index
		}
	}

	const focusoutHandler = () => {
		queueMicrotask(() => {
			const index = findCurrentFocusedRow()
			if (index === -1) {
				focusIndex = -1
			}
		})
	}
</script>

{#if count === 0}
	<div class="m-auto h-max w-max self-center justify-self-center text-center">
		{m.noItemsToDisplay()}
	</div>
{:else}
	<div
		bind:this={container}
		bind:offsetWidth
		role="grid"
		aria-rowcount={count}
		style:height={`${virtualizer.getTotalSize() - gap}px`}
		class="@container relative w-full rounded-lg -outline-offset-2 contain-strict"
		tabindex="0"
		onfocusin={focusinHandler}
		onfocusout={focusoutHandler}
		onkeydown={keydownHandler}
	>
		{#each virtualizer.getVirtualItems() as virtualItem (key(virtualItem.index))}
			{@render children(virtualItem)}
		{/each}
	</div>
{/if}


================================================
FILE: src/lib/components/WrapTranslation.svelte
================================================
<script lang="ts" generics="Params extends Record<string, unknown>">
	type Props = {
		[K in keyof Params]: Snippet
	} & {
		messageFn: (value: Params) => string
	}

	const { messageFn, ...props }: Props = $props()

	const partMarker = '__PART_MARKER__'
	const valueMarker = '__VALUE_MARKER__'

	const parts = $derived.by(() => {
		const paramsKeys = Object.keys(props)

		const placeholdersEntries = paramsKeys.map(
			(key) => [key, `${partMarker}${valueMarker}${key}${partMarker}`] as const,
		)

		const placeholderParams = Object.fromEntries(placeholdersEntries) as Params
		const message = messageFn(placeholderParams)

		return message.split(partMarker)
	})
</script>

<div>
	{#each parts as part}
		{#if part.startsWith(valueMarker)}
			{@render props[part.replaceAll(valueMarker, '')]?.()}
		{:else}
			{part}
		{/if}
	{/each}
</div>


================================================
FILE: src/lib/components/animated-icons/PlayPauseIcon.svelte
================================================
<script lang="ts">
	interface Props {
		playing?: boolean
	}

	const { playing = false }: Props = $props()
</script>

<div class={['play-icon relative z-1 size-6', playing && 'playing rotate-90']}>
	<div class="play-bar"></div>
	<div class="play-bar flip-y"></div>
</div>

<style>
	.play-icon {
		transition: rotate 0.2s ease-out;
	}

	.play-bar {
		background: currentcolor;
		height: 50%;
		clip-path: polygon(32% 40%, 82% 102%, 82% 102%, 32% 102%);
		transition: clip-path 0.2s ease-out;
		.playing & {
			clip-path: polygon(22% 50%, 80% 50%, 80% 84%, 22% 84%);
		}
	}
</style>


================================================
FILE: src/lib/components/animated-icons/PlayPreviousNextIcon.svelte
================================================
<script lang="ts">
	import { on } from 'svelte/events'
	import { wait } from '$lib/helpers/utils/wait.ts'

	interface Props {
		type: 'next' | 'previous'
	}

	const { type }: Props = $props()

	const flipIcon = $derived(type === 'previous')

	let isAnimating = $state(false)

	const action = (target: HTMLDivElement) => {
		let button = target.parentElement

		while (button) {
			if (button.tagName === 'BUTTON') {
				break
			}

			button = button.parentElement
		}

		invariant(button, 'No button found')

		const cleanup = on(button, 'click', async () => {
			if ((button as HTMLButtonElement).disabled) {
				return
			}

			isAnimating = true
			await wait(200)
			isAnimating = false
		})

		return cleanup
	}
</script>

<div
	class={[flipIcon && 'flip-x', 'grid']}
	data-icon-animating={isAnimating ? '' : undefined}
	{@attach action}
>
	<!-- Cannot add clip on svg itself because of Safari bug  -->
	<div class="icon-clip stack-in-grid">
		<svg class="size-6 fill-current" viewBox="0 0 24 24">
			<path class="skip-top" d="M 6,18 14.5,12 6,6 M 8,9.86 11.03,12 8,14.14" />
			<path class="skip-bottom invisible" d="M 6,18 14.5,12 6,6 M 8,9.86 11.03,12 8,14.14" />
		</svg>
	</div>
	<svg class="size-6 fill-current stack-in-grid" viewBox="0 0 24 24">
		<path d="M16,6L16,18L18,18L18,6L16,6Z" />
	</svg>
</div>

<style>
	.icon-clip {
		clip-path: inset(0 8px 0 6px);
	}

	@keyframes skipTopAni {
		from {
			transform: translate(0px, 0px);
		}
		to {
			transform: translate(10px, 0px);
		}
	}

	[data-icon-animating] .skip-top {
		animation: skipTopAni 0.2s ease-out;
	}

	@keyframes skipBottomAni {
		from {
			transform: translate(-10px, 0px);
		}
		to {
			transform: translate(0px, 0px);
		}
	}

	[data-icon-animating] .skip-bottom {
		animation: skipBottomAni 0.2s ease-out;
		visibility: visible;
		transform-origin: left center;
	}
</style>


================================================
FILE: src/lib/components/dialog/CommonDialog.svelte
================================================
<script module lang="ts">
	import Dialog, { type DialogData, type DialogOpen, type DialogProps } from './Dialog.svelte'
	import DialogFooter, { type DialogButton } from './DialogFooter.svelte'

	export interface CommonDialogProps<Open extends DialogOpen> extends DialogProps<Open> {
		buttons?: DialogButton[] | ((data: DialogData<Open>) => DialogButton[])
		onsubmit?: (e: SubmitEvent, data: DialogData<Open>) => void
	}
</script>

<script lang="ts" generics="Open extends DialogOpen">
	let {
		open = $bindable(false) as Open,
		buttons,
		onsubmit,
		children: externalChildren,
		class: className,
		...props
	}: CommonDialogProps<Open> = $props()

	const getButtonItems = (data: DialogData<Open>) => {
		if (typeof buttons === 'function') {
			return buttons(data)
		}

		return buttons
	}
</script>

<Dialog bind:open class={className} {...props}>
	{#snippet children({ data, close })}
		<form
			data-dialog-body
			method="dialog"
			class="contents"
			onsubmit={(e) => {
				e.preventDefault()

				onsubmit?.(e, data)
			}}
		>
			{#if externalChildren}
				<div data-dialog-content class="mt-4 grow px-6 text-onSurfaceVariant">
					{@render externalChildren({ data, close })}
				</div>
			{/if}

			<DialogFooter buttons={getButtonItems(data)} onclose={close} />
		</form>
	{/snippet}
</Dialog>


================================================
FILE: src/lib/components/dialog/Dialog.svelte
================================================
<script module lang="ts">
	import type { AnimationConfig } from 'svelte/animate'
	import { type AnimationSequence, timeline } from '$lib/helpers/animations.ts'
	import Icon, { type IconType } from '../icon/Icon.svelte'

	export interface DialogOpenAccessor<S> {
		get: () => S | null
		close: () => void
	}

	export type DialogOpen<S = unknown> = DialogOpenAccessor<S> | boolean

	export type DialogData<Open extends DialogOpen> =
		Open extends DialogOpenAccessor<infer S> ? S : undefined

	interface DialogBaseProps<Data> {
		title?: string | ((data: Data) => string)
		icon?: IconType
		class?: ClassValue
		header?: Snippet<[{ data: Data; close: () => void }]>
		children?: Snippet<[{ data: Data; close: () => void }]>
	}

	export interface DialogProps<Open extends DialogOpen> extends DialogBaseProps<DialogData<Open>> {
		open: Open
	}
</script>

<script lang="ts" generics="Open extends DialogOpen">
	let {
		open = $bindable(false) as Open,
		title,
		icon,
		class: className,
		header,
		children,
	}: DialogProps<Open> = $props()

	type UnwrapOpen =
		| {
				isOpen: true
				data: DialogData<Open>
		  }
		| {
				isOpen: false
				data: null
		  }

	const state = $derived.by(() => {
		if (typeof open === 'boolean') {
			return {
				isOpen: open,
				data: undefined,
			} as UnwrapOpen
		}

		const data = open.get()

		return {
			isOpen: data !== null,
			data,
		} as UnwrapOpen
	})

	const titleText = $derived.by(() => {
		if (typeof title === 'function') {
			return state.data ? title(state.data) : ''
		}

		return title
	})

	const close = () => {
		if (typeof open === 'object') {
			open.close()
		} else {
			open = false as Open
		}
	}

	const getParts = (dialog: HTMLDialogElement) => {
		const dialogHeader = dialog.querySelector<HTMLElement>('[data-dialog-header]')
		const dialogBody = dialog.querySelector<HTMLElement>('[data-dialog-content]')
		const dialogFooter = dialog.querySelector<HTMLElement>('[data-dialog-footer]')

		return { dialogHeader, dialogBody, dialogFooter }
	}

	const animateBackdrop = (dialog: HTMLDialogElement, isOut = false) => {
		try {
			dialog.animate(
				{
					opacity: isOut ? [1, 0] : [0, 1],
				},
				{
					pseudoElement: '::backdrop',
					duration: 300,
					easing: 'linear',
					fill: isOut ? 'forwards' : undefined,
				},
			)
		} catch (err) {
			// Firefox does not support pseudo-element animations
			// https://bugzilla.mozilla.org/show_bug.cgi?id=1770591
			if (import.meta.env.DEV) {
				console.warn(err)
			}
		}
	}

	const animateIn = (dialog: HTMLDialogElement) => {
		const { dialogHeader, dialogBody, dialogFooter } = getParts(dialog)

		const fade = (el: HTMLElement | null): AnimationSequence | null =>
			el ? [el, { opacity: [0, 1] }, { duration: 300, at: '<' }] : null

		animateBackdrop(dialog)

		const frames: readonly AnimationSequence[] = [
			[
				dialog,
				{
					transform: ['translateY(-20px)', 'none'],
					clipPath: ['inset(0% 0% 100% 0% round 24px)', 'inset(0% 0% 0% 0% round 24px)'],
				},
				{
					duration: 400,
				},
			] satisfies AnimationSequence,
			fade(dialogHeader),
			fade(dialogBody),
			fade(dialogFooter),
			dialogFooter &&
				([
					dialogFooter,
					{ transform: ['translateY(-60px)', 'none'] },
					{ duration: 400, at: '<' },
				] satisfies AnimationSequence),
		].filter((x) => x !== null && x !== undefined)

		timeline(frames, {
			defaultOptions: {
				// ease-standard
				easing: 'cubic-bezier(0.2, 0, 0, 1)',
			},
		})
	}

	const animateOut = (dialog: HTMLDialogElement) => {
		const { dialogHeader, dialogBody, dialogFooter } = getParts(dialog)

		const fade = (el: HTMLElement | null): AnimationSequence | null =>
			el ? [el, { opacity: [1, 0] }, { duration: 300, at: '<' }] : null

		animateBackdrop(dialog, true)

		const frames: readonly AnimationSequence[] = [
			[
				dialog,
				{
					transform: ['none', 'translateY(-20px)'],
					clipPath: ['inset(0% 0% 0% 0% round 24px)', 'inset(0% 0% 100% 0% round 24px)'],
				},
				{
					duration: 400,
				},
			] satisfies AnimationSequence,
			dialogFooter &&
				([
					dialogFooter,
					{ transform: ['none', 'translateY(-60px)'] },
					{ duration: 400, at: '<' },
				] satisfies AnimationSequence),
			fade(dialogFooter),
			fade(dialogBody),
			fade(dialogHeader),
		].filter((x) => x !== null && x !== undefined)

		return timeline(frames, {
			defaultOptions: {
				// ease-standard
				easing: 'cubic-bezier(0.2, 0, 0, 1)',
			},
		})
	}

	const onOpenAction = (dialog: HTMLDialogElement) => {
		dialog.showModal()
		void animateIn(dialog)
	}

	const outAni = (dialog: HTMLDialogElement): AnimationConfig => {
		void animateOut(dialog)

		// TODO. A hack until svelte supports non duration based animations
		return {
			duration: 400,
		}
	}
</script>

{#if state.isOpen}
	<dialog
		{@attach onOpenAction}
		out:outAni
		onkeydown={(e) => {
			if (e.key === 'Escape') {
				close()
				// We don't want dialog to exit top level
				// and instead remain until the animation is complete
				// and then remove from the DOM
				e.preventDefault()
			}
		}}
		onclose={() => {
			// There is no way to prevent dialog close event
			close()
		}}
		class={[
			'm-auto flex flex-col rounded-3xl bg-surfaceContainerHigh text-onSurface contain-content select-none focus:outline-none',
			className,
		]}
	>
		{#if header}
			{@render header({ data: state.data, close })}
		{:else}
			<div
				data-dialog-header
				class={['flex flex-col gap-4 px-6 pt-6', icon && 'items-center justify-center text-center']}
			>
				{#if icon}
					<Icon type={icon} class="text-secondary" />
				{/if}

				{#if titleText}
					<div class="text-headline-sm">{titleText}</div>
				{/if}
			</div>
		{/if}

		<div class="flex shrink flex-col overflow-hidden">
			{@render children?.({
				data: state.data,
				close,
			})}
		</div>
	</dialog>
{/if}

<style lang="postcss">
	@reference '../../../app.css';

	dialog {
		/*
			We want to allow user of dialog to specify their preferred height
			but keep it inside window bounds
		*/
		max-width: initial !important;
		max-height: min(100% - --spacing(6) * 2, var(--dialog-height, 100%), --spacing(150)) !important;
		width: clamp(
			--spacing(70),
			var(--dialog-width, --spacing(100)),
			100% - --spacing(8)
		) !important;
		height: max-content !important;
		overscroll-behavior: contain;
	}

	dialog::backdrop {
		background: rgba(0, 0, 0, 0.22);
		backdrop-filter: blur(4px);
	}
</style>


================================================
FILE: src/lib/components/dialog/DialogFooter.svelte
================================================
<script module lang="ts">
	import Button, { type ButtonKind } from '../Button.svelte'

	export interface DialogButton<S = void> {
		title: string
		align?: 'left'
		kind?: ButtonKind
		type?: 'submit' | 'button' | 'reset' | 'close'
		action?: (data: S) => void | Promise<void>
	}

	export interface DialogButtonProps<S = void> {
		buttons?: DialogButton<S>[]
		state?: S
		onclose: () => void
	}
</script>

<script lang="ts" generics="S">
	let { buttons = [], onclose, state }: DialogButtonProps<S> = $props()
</script>

{#if buttons?.length}
	<div data-dialog-footer class="flex justify-end gap-2 p-6">
		{#each buttons as button}
			<Button
				kind={button.kind ?? 'flat'}
				class={['min-w-15', button.align === 'left' && 'mr-auto']}
				type={button.type === 'close' ? 'button' : button.type}
				onclick={async () => {
					await button.action?.(state as S)
					if (!button.type || button.type === 'close') {
						onclose()
					}
				}}
			>
				{button.title}
			</Button>
		{/each}
	</div>
{/if}


================================================
FILE: src/lib/components/global-dialogs/EqualizerDialog.svelte
================================================
<script lang="ts" module>
	import Button from '$lib/components/Button.svelte'
	import Dialog, { type DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'
	import Separator from '$lib/components/Separator.svelte'
	import Slider from '$lib/components/Slider.svelte'
	import Switch from '$lib/components/Switch.svelte'
	import {
		type BuiltinEqPresetKey,
		EQ_BANDS,
		EQ_MAX_GAIN,
		EQ_MIN_GAIN,
	} from '$lib/stores/player/equalizer.svelte.ts'

	export interface EqualizerDialogProps {
		open: DialogOpenAccessor<boolean>
	}
</script>

<script lang="ts">
	let { open }: EqualizerDialogProps = $props()

	const player = usePlayer()
	const eq = $derived(player.equalizer)

	const presets: [BuiltinEqPresetKey, string][] = [
		['flat', m.equalizerPresetFlat()],
		['bassBoost', m.equalizerPresetBassBoost()],
		['trebleBoost', m.equalizerPresetTrebleBoost()],
		['rock', m.equalizerPresetRock()],
		['pop', m.equalizerPresetPop()],
		['jazz', m.equalizerPresetJazz()],
		['classical', m.equalizerPresetClassical()],
		['electronic', m.equalizerPresetElectronic()],
		['acoustic', m.equalizerPresetAcoustic()],
	]
</script>

<Dialog {open} class="[--dialog-width:--spacing(160)]">
	{#snippet header()}
		<header data-dialog-header class="flex items-center justify-between px-6 py-6">
			<div class="text-headline-sm">{m.equalizerTitle()}</div>

			<Switch bind:checked={eq.enabled} />
		</header>
	{/snippet}

	{#snippet children({ close })}
		<div data-dialog-content class="flex flex-col">
			<Separator />

			<div class="mb-2 flex gap-2 overflow-x-auto px-6 pt-4 pb-4">
				{#each presets as [preset, label]}
					<button
						type="button"
						class={[
							'interactable shrink-0 rounded-full px-3 py-1 text-label-lg transition-colors',
							eq.selectedPreset === preset
								? 'bg-primary text-onPrimary'
								: 'bg-secondaryContainer text-onSecondaryContainer',
						]}
						onclick={() => eq.applyPreset(preset)}
					>
						{label}
					</button>
				{/each}
			</div>

			<div
				class="sliders-columns grid gap-3 overflow-x-auto overflow-y-hidden overscroll-none px-4 pb-3"
			>
				{#each EQ_BANDS as band, i}
					{@const gain = eq.bands[i] ?? 0}
					<div class="flex flex-col items-center gap-2">
						<span class="text-label-sm tabular-nums">
							{gain > 0 ? '+' : ''}{Math.round(gain)}
						</span>
						<div class="h-40">
							<Slider
								vertical
								min={EQ_MIN_GAIN}
								max={EQ_MAX_GAIN}
								step={0.5}
								bind:value={() => gain, (v) => eq.setBand(i, v)}
								disabled={!eq.enabled}
							/>
						</div>
						<span class="text-label-sm text-onSurfaceVariant tabular-nums">{band.label}</span>
					</div>
				{/each}
			</div>
		</div>

		<div data-dialog-footer class="flex items-center justify-between px-6 pt-3 pb-6">
			<Button kind="outlined" onclick={() => eq.reset()}>{m.equalizerReset()}</Button>
			<Button kind="flat" onclick={close}>{m.equalizerClose()}</Button>
		</div>
	{/snippet}
</Dialog>

<style lang="postcss">
	@reference '../../../app.css';
	.sliders-columns {
		grid-template-columns: repeat(10, minmax(--spacing(12), 1fr));
	}
</style>


================================================
FILE: src/lib/components/global-dialogs/RemoveFromLibraryDialog.svelte
================================================
<script lang="ts" module>
	import CommonDialog from '$lib/components/dialog/CommonDialog.svelte'
	import { createUIAction } from '$lib/helpers/ui-action'
	import { truncate } from '$lib/helpers/utils/text.ts'
	import { dbRemovePlaylist } from '$lib/library/playlists-actions.ts'
	import { dbRemoveAlbum, dbRemoveArtist, dbRemoveTracks } from '$lib/library/remove.ts'
	import type { LibraryStoreName } from '$lib/library/types'
	import type { DialogOpenAccessor } from '../dialog/Dialog.svelte'

	type RemoveLibraryItemOptions =
		| {
				type: 'single'
				id: number
				name: string
				storeName: LibraryStoreName
		  }
		| {
				type: 'multiple'
				ids: readonly number[]
				storeName: 'tracks'
		  }

	export interface RemoveFromLibraryDialogProps {
		open: DialogOpenAccessor<RemoveLibraryItemOptions>
	}
</script>

<script lang="ts">
	let { open }: RemoveFromLibraryDialogProps = $props()

	const removeSingle = createUIAction(
		m.libraryItemRemovedFromLibrary(),
		(store: LibraryStoreName, id: number) => {
			switch (store) {
				case 'playlists':
					return dbRemovePlaylist(id)
				case 'tracks':
					return dbRemoveTracks([id])
				case 'albums':
					return dbRemoveAlbum(id)
				case 'artists':
					return dbRemoveArtist(id)
			}
		},
	)

	const removeMultiple = createUIAction(
		m.libraryItemsRemovedFromLibrary(),
		(store: LibraryStoreName, ids: readonly number[]) => {
			invariant(store === 'tracks', 'Only tracks can be removed in bulk')

			return dbRemoveTracks(ids)
		},
	)
</script>

<CommonDialog
	{open}
	title={(data) => {
		if (data.type === 'multiple') {
			return m.libraryConfirmRemoveMultipleTitle({
				count: data.ids.length,
			})
		}

		return m.libraryConfirmRemoveTitle({
			name: truncate(data.name ?? '', 10),
		})
	}}
	buttons={[
		{
			title: m.libraryCancel(),
		},
		{
			title: m.libraryRemove(),
			type: 'submit',
		},
	]}
	onsubmit={(_, data) => {
		open.close()

		if (data.type === 'multiple') {
			void removeMultiple(data.storeName, data.ids)
			return
		}

		void removeSingle(data.storeName, data.id)
	}}
/>


================================================
FILE: src/lib/components/global-dialogs/dialogs.ts
================================================
import type { Component } from 'svelte'
import type { DialogOpenAccessor } from '../dialog/Dialog.svelte'
import EqualizerDialog from './EqualizerDialog.svelte'
import AddToPlaylistDialog from './playlists/AddToPlaylistDialog.svelte'
import EditPlaylistDialog from './playlists/EditPlaylistDialog.svelte'
import NewPlaylistDialog from './playlists/NewPlaylistDialog.svelte'
import RemoveFromLibraryDialog from './RemoveFromLibraryDialog.svelte'

// biome-ignore lint/suspicious/noExplicitAny: needed for inference
type ComponentWithOpenProp = Component<{ open: DialogOpenAccessor<any> }>

export const APP_DIALOGS_COMPONENTS_MAP = {
	equalizer: EqualizerDialog,
	removeFromLibrary: RemoveFromLibraryDialog,
	addToPlaylist: AddToPlaylistDialog,
	newPlaylist: NewPlaylistDialog,
	editPlaylist: EditPlaylistDialog,
} satisfies Record<string, ComponentWithOpenProp>

export type AppDialogKey = keyof typeof APP_DIALOGS_COMPONENTS_MAP

export const APP_DIALOGS_KEYS = Object.keys(APP_DIALOGS_COMPONENTS_MAP) as AppDialogKey[]


================================================
FILE: src/lib/components/global-dialogs/playlists/AddToPlaylistDialog.svelte
================================================
<script lang="ts" module>
	import Dialog, { type DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'
	import DialogFooter from '$lib/components/dialog/DialogFooter.svelte'
	import Separator from '$lib/components/Separator.svelte'
	import AddToPlaylistDialogContent from './AddToPlaylistDialogContent.svelte'

	export interface AddToPlaylistDialogProps {
		open: DialogOpenAccessor<readonly number[]>
	}
</script>

<script lang="ts">
	let { open }: AddToPlaylistDialogProps = $props()

	const dialogs = useDialogsStore()

	const dialogTitle = (tracks: readonly number[]) => {
		const count = tracks.length ?? 0
		const countLabel = count > 1 ? ` (${count})` : ''

		return `${m.libraryAddToPlaylist()}${countLabel}`
	}
</script>

<Dialog {open} title={dialogTitle}>
	{#snippet children({ data: trackIds, close })}
		<svelte:boundary
			onerror={(e) => {
				snackbar.unexpectedError(e)
				queueMicrotask(() => {
					close()
				})
			}}
		>
			<AddToPlaylistDialogContent {trackIds}>
				{#snippet children({ save })}
					<Separator />
					<DialogFooter
						buttons={[
							{
								title: m.libraryCreateNewPlaylist(),
								align: 'left',
								type: 'button',
								action: () => {
									dialogs.openDialog('newPlaylist')
								},
							},
							{
								title: m.libraryCancel(),
							},
							{
								title: m.librarySave(),
								action: save,
							},
						]}
						onclose={close}
					/>
				{/snippet}
			</AddToPlaylistDialogContent>
		</svelte:boundary>
	{/snippet}
</Dialog>


================================================
FILE: src/lib/components/global-dialogs/playlists/AddToPlaylistDialogContent.svelte
================================================
<script lang="ts">
	import { SvelteMap } from 'svelte/reactivity'
	import Icon from '$lib/components/icon/Icon.svelte'
	import PlaylistListContainer from '$lib/components/playlists/PlaylistListContainer.svelte'
	import ScrollContainer from '$lib/components/ScrollContainer.svelte'
	import Separator from '$lib/components/Separator.svelte'
	import TextField from '$lib/components/TextField.svelte'
	import { getDatabase } from '$lib/db/database.ts'
	import { createInlineQuery } from '$lib/db/query/inline-query.svelte'
	import { createQuery } from '$lib/db/query/query.ts'
	import { getLibraryItemIds } from '$lib/library/get/ids'
	import { dbBatchModifyPlaylistsSelection } from '$lib/library/playlists-actions'

	interface Props {
		trackIds: readonly number[]
		children: Snippet<[{ save: () => Promise<void> }]>
	}

	const { trackIds, children }: Props = $props()

	let searchTerm = $state('')

	const getPlaylists = createInlineQuery({
		key: () => [searchTerm],
		fetcher: () =>
			getLibraryItemIds('playlists', {
				sort: 'createdAt',
				order: 'desc',
				searchTerm: searchTerm.trim().toLowerCase(),
				searchFn: (p, term) => p.name.trim().toLowerCase().includes(term),
			}),
		onDatabaseChange: (changes) => {
			for (const change of changes) {
				if (change.storeName === 'playlists') {
					return true
				}
			}

			return false
		},
	})

	const playlistsIds = $derived(await getPlaylists())

	const initialTrackPlaylists = createQuery({
		// We only care about initial values
		key: [],
		fetcher: async () => {
			const firstTrackId = trackIds.at(0)
			// In case there are multiple track ids, we treat as if there are no items added in the playlist
			if (trackIds.length > 1 || !firstTrackId) {
				return null
			}

			const db = await getDatabase()
			const items = await db.getAllFromIndex('playlistEntries', 'trackId', firstTrackId)

			return items
		},
	})

	type SelectionStatus = 'added-already' | 'add' | 'remove'
	const selection = new SvelteMap</* playlist id */ number, SelectionStatus>()

	$effect(() => {
		if (initialTrackPlaylists.status === 'loaded') {
			untrack(() => {
				for (const playlistEntry of initialTrackPlaylists.value ?? []) {
					selection.set(playlistEntry.playlistId, 'added-already')
				}
			})
		}
	})

	const isTrackInPlaylist = (playlistId: number) => {
		const selectionState = selection.get(playlistId)

		return selectionState === 'added-already' || selectionState === 'add'
	}

	const toggleSelection = (playlistId: number) => {
		const selectionState = selection.get(playlistId)

		if (selectionState === 'added-already') {
			selection.set(playlistId, 'remove')
		} else if (selectionState === 'add') {
			selection.delete(playlistId)
		} else if (selectionState === 'remove') {
			selection.set(playlistId, 'added-already')
		} else {
			selection.set(playlistId, 'add')
		}
	}

	const dbSave = () => {
		const playlistsIdsRemoveFrom: number[] = []
		const playlistsIdsAddTo: number[] = []
		for (const [playlistId, status] of selection) {
			if (status === 'remove') {
				playlistsIdsRemoveFrom.push(playlistId)
			} else if (status === 'add') {
				playlistsIdsAddTo.push(playlistId)
			}
		}

		return dbBatchModifyPlaylistsSelection({
			trackIds,
			playlistsIdsAddTo,
			playlistsIdsRemoveFrom,
		})
	}

	const save = async () => {
		try {
			const changed = await dbSave()
			if (changed) {
				snackbar({ id: 'playlists-updated', message: m.libraryPlaylistsUpdated() })
			}
		} catch (error) {
			snackbar.unexpectedError(error)
		}
	}
</script>

<div class="p-4">
	<TextField bind:value={searchTerm} name="search" placeholder={m.librarySearch()} />
</div>

<Separator />
<ScrollContainer class="max-h-100 grow overflow-auto px-2 py-4">
	<PlaylistListContainer
		items={playlistsIds}
		onItemClick={(item) => {
			toggleSelection(item.playlist.id)
		}}
	>
		{#snippet icon(playlist)}
			{@const isInPlaylist = isTrackInPlaylist(playlist.id)}
			<div
				class={[
					'flex size-6 items-center justify-center rounded-full border-2',
					isInPlaylist ? 'border-primary bg-primary text-onPrimary' : 'border-neutral',
				]}
			>
				{#if isInPlaylist}
					<Icon type="check" />
				{/if}
			</div>
		{/snippet}
	</PlaylistListContainer>
</ScrollContainer>

{@render children({ save })}


================================================
FILE: src/lib/components/global-dialogs/playlists/EditPlaylistDialog.svelte
================================================
<script lang="ts" module>
	import CommonDialog from '$lib/components/dialog/CommonDialog.svelte'
	import type { DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'
	import TextField from '$lib/components/TextField.svelte'
	import { type UpdatePlaylistOptions, updatePlaylist } from '$lib/library/playlists-actions'

	export interface EditPlaylistDialogProps {
		open: DialogOpenAccessor<UpdatePlaylistOptions>
	}
</script>

<script lang="ts">
	let { open }: EditPlaylistDialogProps = $props()

	const submitHandler = async (event: SubmitEvent, data: UpdatePlaylistOptions) => {
		invariant(data !== null, 'Playlist to edit is not set')

		const formData = new FormData(event.target as HTMLFormElement)
		const name = formData.get('playlistName') as string
		const description = formData.get('description') as string

		const success = await updatePlaylist({
			id: data.id,
			name,
			description,
		})

		if (success) {
			open.close()
		}
	}
</script>

<CommonDialog
	{open}
	icon="addPlaylist"
	title={m.libraryEditPlaylistName()}
	buttons={[
		{
			title: m.libraryCancel(),
		},
		{
			title: m.librarySave(),
			type: 'submit',
		},
	]}
	onsubmit={submitHandler}
>
	{#snippet children({ data })}
		<TextField
			value={data.name}
			name="playlistName"
			placeholder={m.libraryPlaylistName()}
			required
			minLength={4}
			maxLength={40}
		/>

		<TextField
			value={data.description}
			name="description"
			placeholder={m.description()}
			maxLength={200}
			class="mt-6"
		/>
	{/snippet}
</CommonDialog>


================================================
FILE: src/lib/components/global-dialogs/playlists/NewPlaylistDialog.svelte
================================================
<script lang="ts" module>
	import CommonDialog from '$lib/components/dialog/CommonDialog.svelte'
	import type { DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'
	import TextField from '$lib/components/TextField.svelte'
	import { createPlaylist } from '$lib/library/playlists-actions.ts'

	export interface NewPlaylistDialogProps {
		open: DialogOpenAccessor<boolean>
	}
</script>

<script lang="ts">
	let { open }: NewPlaylistDialogProps = $props()

	const onSubmitHandler = async (event: SubmitEvent) => {
		const formData = new FormData(event.target as HTMLFormElement)
		const name = formData.get('playlistName') as string
		const description = formData.get('description') as string

		await createPlaylist(name, description)

		open.close()
	}
</script>

<CommonDialog
	{open}
	icon="addPlaylist"
	title={m.libraryCreateNewPlaylist()}
	buttons={[
		{
			title: m.libraryCancel(),
		},
		{
			title: m.libraryCreate(),
			type: 'submit',
		},
	]}
	onsubmit={onSubmitHandler}
>
	<TextField
		name="playlistName"
		placeholder={m.libraryPlaylistName()}
		required
		minLength={4}
		maxLength={40}
	/>

	<TextField name="description" placeholder={m.description()} maxLength={200} class="mt-6" />
</CommonDialog>


================================================
FILE: src/lib/components/icon/Icon.svelte
================================================
<script lang="ts" module>
	import type { IconType } from './icon-paths.server.ts'

	export type { IconType } from './icon-paths.server.ts'

	export interface IconProps {
		type: IconType
		class?: ClassValue
	}
</script>

<script lang="ts">
	const { type, class: className }: IconProps = $props()
</script>

<svg
	role="presentation"
	width="24"
	height="24"
	viewBox="0 0 24 24"
	class={['pointer-events-none shrink-0 fill-current', className]}
>
	<use href={`#system-icon-${type}`} />
</svg>


================================================
FILE: src/lib/components/icon/icon-paths.server.ts
================================================
// Icons taken from https://pictogrammers.com/library/mdi/
// and then minified using https://jakearchibald.github.io/svgomg/

export const ICON_PATHS = {
	addPlaylist: 'M2 16h8v-2H2m16 0v-4h-2v4h-4v2h4v4h2v-4h4v-2m-8-8H2v2h12m0 2H2v2h12v-2z',
	album: 'M12 11a1 1 0 00-1 1 1 1 0 001 1 1 1 0 001-1 1 1 0 00-1-1m0 5.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5M12 2A10 10 0 002 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2z',
	alertCircle:
		'M11 15h2v2h-2v-2m0-8h2v6h-2V7m1-5C6.47 2 2 6.5 2 12a10 10 0 0010 10 10 10 0 0010-10A10 10 0 0012 2m0 18a8 8 0 01-8-8 8 8 0 018-8 8 8 0 018 8 8 8 0 01-8 8z',
	backArrow: 'M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z',
	cached: 'M19,8L15,12H18A6,6 0 0,1 12,18C11,18 10.03,17.75 9.2,17.3L7.74,18.76C8.97,19.54 10.43,20 12,20A8,8 0 0,0 20,12H23M6,12A6,6 0 0,1 12,6C13,6 13.97,6.25 14.8,6.7L16.26,5.24C15.03,4.46 13.57,4 12,4A8,8 0 0,0 4,12H1L5,16L9,12',
	cellphone:
		'M17 19H7V5h10m0-4H7c-1.11 0-2 .89-2 2v18c0 1.11.89 2 2 2h10c1.11 0 2-.89 2-2V3c0-1.11-.89-2-2-2Z',
	check: 'M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59 21 7Z',
	chevronRight: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z',
	chevronUp: 'M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z',
	close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z',
	delete: 'M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 002 2h8a2 2 0 002-2V7H6v12z',
	discord:
		'M19.952 5.672c-1.904-1.531-4.916-1.79-5.044-1.801a.477.477 0 0 0-.474.281 3.715 3.715 0 0 0-.145.398c1.259.212 2.806.64 4.206 1.509a.48.48 0 0 1-.505.813C15.584 5.38 12.578 5.305 12 5.305s-3.585.075-5.989 1.567a.479.479 0 1 1-.505-.813c1.4-.868 2.946-1.297 4.206-1.509-.074-.236-.14-.386-.145-.398a.473.473 0 0 0-.475-.28c-.127.01-3.139.269-5.069 1.822C3.015 6.625 1 12.073 1 16.783a.48.48 0 0 0 .063.237c1.391 2.443 5.185 3.083 6.05 3.111h.015a.478.478 0 0 0 .387-.197l.875-1.202c-2.359-.61-3.564-1.645-3.634-1.706a.478.478 0 0 1 .632-.718c.029.026 2.248 1.909 6.612 1.909 4.372 0 6.591-1.891 6.613-1.91a.479.479 0 0 1 .632.718c-.07.062-1.275 1.096-3.634 1.706l.875 1.202c.09.124.234.197.387.197h.015c.865-.027 4.659-.667 6.05-3.111a.486.486 0 0 0 .062-.236c0-4.71-2.015-10.158-3.048-11.111zM8.891 14.87c-.924 0-1.674-.857-1.674-1.913s.749-1.913 1.674-1.913 1.674.857 1.674 1.913-.749 1.913-1.674 1.913zm6.218 0c-.924 0-1.674-.857-1.674-1.913s.749-1.913 1.674-1.913c.924 0 1.674.857 1.674 1.913s-.75 1.913-1.674 1.913z',
	dragHorizontal: 'M21 11H3V9H21V11M21 13H3V15H21V13Z',
	equalizer: 'M10,20H14V4H10V20M4,20H8V12H4V20M16,9V20H20V9H16Z',
	eyedropper:
		'M6.92 19 5 17.08 13.06 9 15 10.94m5.71-5.31-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.05.01-1.42Z',
	favorite:
		'M12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5 2 5.41 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.41 22 8.5c0 3.77-3.4 6.86-8.55 11.53L12 21.35z',
	favoriteOutline:
		'M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z',
	flash: 'M7 2v11h3v9l7-12h-4l3-8H7Z',
	folder: 'M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.9-2-2-2h-8l-2-2Z',
	folderHidden:
		'M9 4v4H6V4h3M4 16v-3H2v3h2m-2-4h2V9H2v3m16-4h4c0-1.11-.9-2-2-2h-2v2m4 5h-2v3h2v-3m-2-4v3h2V9h-2M9 20v-2H6v2h3m-4-2H4v-1H2v1c0 1.11.9 2 2 2h1v-2m15-1v1h-2v2h2c1.11 0 2-.89 2-2v-1h-2M4 8h1V4H4c-1.11 0-2 .89-2 2v2h2m13 10h-3v2h3v-2m-4 0h-3v2h3v-2m4-12h-3v2h3V6m-7 2h3V6h-1l-2-2v4Z',
	github: 'M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z',
	headphones:
		'M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9Z',
	home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8h5Z',
	information:
		'M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2Z',
	lock: 'M12 17a2 2 0 0 0 2-2c0-1.11-.89-2-2-2a2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3Z',
	lockCheck:
		'M14 15c0 1.11-.89 2-2 2a2 2 0 0 1-2-2c0-1.11.89-2 2-2a2 2 0 0 1 2 2m-.91 5c.12.72.37 1.39.72 2H6a2 2 0 0 1-2-2V10c0-1.11.89-2 2-2h1V6c0-2.76 2.24-5 5-5s5 2.24 5 5v2h1a2 2 0 0 1 2 2v3.09c-.33-.05-.66-.09-1-.09-.34 0-.67.04-1 .09V10H6v10h7.09M9 8h6V6c0-1.66-1.34-3-3-3S9 4.34 9 6v2m12.34 7.84-3.59 3.59-1.59-1.59L15 19l2.75 3 4.75-4.75-1.16-1.41Z',
	magnify:
		'M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27C12.59 15.41 11.11 16 9.5 16A6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z',
	menuDown: 'M7,10L12,15L17,10H7Z',
	moreVertical:
		'M12 16a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0-6a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0-6a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2z',
	musicNote:
		'M12 3v9.26c-.5-.17-1-.26-1.5-.26C8 12 6 14 6 16.5S8 21 10.5 21s4.5-2 4.5-4.5V6h4V3h-7z',
	openInNew:
		'M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7Z',
	palette:
		'M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5 1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9 9 9 0 0 0 9 9 1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8Z',
	person: 'M12 4a4 4 0 014 4 4 4 0 01-4 4 4 4 0 01-4-4 4 4 0 014-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4z',
	playlist:
		'M15 6H3v2h12V6m0 4H3v2h12v-2M3 16h8v-2H3v2M17 6v8.18c-.31-.11-.65-.18-1-.18a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V8h3V6h-5z',
	playlistMusic:
		'M15 6H3v2h12V6m0 4H3v2h12v-2M3 16h8v-2H3v2M17 6v8.18c-.31-.11-.65-.18-1-.18a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V8h3V6h-5Z',
	plus: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z',
	search: 'M9.5 3A6.5 6.5 0 0116 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27C12.59 15.41 11.11 16 9.5 16A6.5 6.5 0 013 9.5 6.5 6.5 0 019.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5z',
	shuffle:
		'M14.83,13.41L13.42,14.82L16.55,17.95L14.5,20H20V14.5L17.96,16.54L14.83,13.41M14.5,4L16.54,6.04L4,18.59L5.41,20L17.96,7.46L20,9.5V4M10.59,9.17L5.41,4L4,5.41L9.17,10.58L10.59,9.17Z',
	sidePanel:
		'M 5 21 C 4.449219 21 3.980469 20.804688 3.585938 20.414062 C 3.195312 20.019531 3 19.550781 3 19 L 3 5 C 3 4.449219 3.195312 3.980469 3.585938 3.585938 C 3.980469 3.195312 4.449219 3 5 3 L 19 3 C 19.550781 3 20.019531 3.195312 20.414062 3.585938 C 20.804688 3.980469 21 4.449219 21 5 L 21 19 C 21 19.550781 20.804688 20.019531 20.414062 20.414062 C 20.019531 20.804688 19.550781 21 19 21 Z M 12 19 L 19 19 L 19 5 L 12 5 Z M 12 19',
	sort: 'M3 13h12v-2H3m0-5v2h18V6M3 18h6v-2H3v2z',
	sortAscending:
		'M19 17H22L18 21L14 17H17V3H19V17M7 3C4.79 3 3 4.79 3 7S4.79 11 7 11 11 9.21 11 7 9.21 3 7 3M7 9C5.9 9 5 8.1 5 7S5.9 5 7 5 9 5.9 9 7 8.1 9 7 9M7 13C4.79 13 3 14.79 3 17S4.79 21 7 21 11 19.21 11 17 9.21 13 7 13Z',
	trashOutline:
		'M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12M8 9h8v10H8V9m7.5-5-1-1h-5l-1 1H5v2h14V4h-3.5Z',
	trayFull:
		'M18 5H6V7H18M6 9H18V11H6M2 12H4V17H20V12H22V17A2 2 0 0 1 20 19H4A2 2 0 0 1 2 17M18 13H6V15H18Z',
	trayRemove:
		'M2 17a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5h-2v5H4v-5H2m12.12-6.54 1.42 1.42L13.41 9l2.13 2.12-1.42 1.42L12 10.41l-2.12 2.13-1.42-1.42L10.59 9 8.46 6.88l1.42-1.42L12 7.59Z',
	volumeHigh:
		'M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.5,8.71 14,7.97V16C15.5,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z',
	volumeMid:
		'M5,9V15H9L14,20V4L9,9M18.5,12C18.5,10.23 17.5,8.71 16,7.97V16C17.5,15.29 18.5,13.76 18.5,12Z',
} as const

/** @public */
export type IconType = keyof typeof ICON_PATHS


================================================
FILE: src/lib/components/library-grid/LibraryGridItem.svelte
================================================
<script lang="ts" module>
	import { goto } from '$app/navigation'
	import { resolve } from '$app/paths'
	import { page } from '$app/state'
	import type { RouteId } from '$app/types'
	import { ripple } from '$lib/attachments/ripple.ts'
	import type { QueryResult } from '$lib/db/query/query.ts'
	import { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte.ts'
	import { dbGetAlbumTracksIdsByName, dbGetArtistTra
Download .txt
gitextract_z80t98b9/

├── .env.example
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── AGENTS.md
├── LICENSE.txt
├── README.md
├── biome.jsonc
├── knip.json
├── lib/
│   ├── vite-image-metadata.ts
│   └── vite-log-chunk-size.ts
├── messages/
│   ├── de.json
│   ├── en.json
│   ├── fr.json
│   ├── lt.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── netlify.toml
├── package.json
├── patches/
│   └── @material__material-color-utilities.patch
├── pnpm-workspace.yaml
├── project.inlang/
│   └── settings.json
├── scripts/
│   ├── check-translations.ts
│   └── gen-color-theme.ts
├── src/
│   ├── ambient.d.ts
│   ├── app.css
│   ├── app.d.ts
│   ├── app.html
│   ├── hooks.server.ts
│   ├── lib/
│   │   ├── app-metadata.ts
│   │   ├── attachments/
│   │   │   ├── ripple.ts
│   │   │   └── tooltip.ts
│   │   ├── components/
│   │   │   ├── AlbumsListContainer.svelte
│   │   │   ├── ArtistListContainer.svelte
│   │   │   ├── Artwork.svelte
│   │   │   ├── BackButton.svelte
│   │   │   ├── Button.svelte
│   │   │   ├── FavoriteButton.svelte
│   │   │   ├── Header.svelte
│   │   │   ├── IconButton.svelte
│   │   │   ├── ListDetailsLayout.svelte
│   │   │   ├── ListItem.svelte
│   │   │   ├── MenuButton.svelte
│   │   │   ├── PlayerOverlay.svelte
│   │   │   ├── ScrollContainer.svelte
│   │   │   ├── Select.svelte
│   │   │   ├── Separator.svelte
│   │   │   ├── Slider.svelte
│   │   │   ├── Spinner.svelte
│   │   │   ├── Switch.svelte
│   │   │   ├── Tabs.svelte
│   │   │   ├── TextField.svelte
│   │   │   ├── VirtualContainer.svelte
│   │   │   ├── WrapTranslation.svelte
│   │   │   ├── animated-icons/
│   │   │   │   ├── PlayPauseIcon.svelte
│   │   │   │   └── PlayPreviousNextIcon.svelte
│   │   │   ├── dialog/
│   │   │   │   ├── CommonDialog.svelte
│   │   │   │   ├── Dialog.svelte
│   │   │   │   └── DialogFooter.svelte
│   │   │   ├── global-dialogs/
│   │   │   │   ├── EqualizerDialog.svelte
│   │   │   │   ├── RemoveFromLibraryDialog.svelte
│   │   │   │   ├── dialogs.ts
│   │   │   │   └── playlists/
│   │   │   │       ├── AddToPlaylistDialog.svelte
│   │   │   │       ├── AddToPlaylistDialogContent.svelte
│   │   │   │       ├── EditPlaylistDialog.svelte
│   │   │   │       └── NewPlaylistDialog.svelte
│   │   │   ├── icon/
│   │   │   │   ├── Icon.svelte
│   │   │   │   └── icon-paths.server.ts
│   │   │   ├── library-grid/
│   │   │   │   ├── LibraryGridItem.svelte
│   │   │   │   └── LibraryGridListContainer.svelte
│   │   │   ├── menu/
│   │   │   │   ├── Menu.svelte
│   │   │   │   ├── MenuRenderer.svelte
│   │   │   │   ├── positioning.ts
│   │   │   │   └── types.ts
│   │   │   ├── player/
│   │   │   │   ├── MainControls.svelte
│   │   │   │   ├── PlayerArtwork.svelte
│   │   │   │   ├── Timeline.svelte
│   │   │   │   ├── VolumeSlider.svelte
│   │   │   │   └── buttons/
│   │   │   │       ├── ActiveIndicator.svelte
│   │   │   │       ├── PlayNextButton.svelte
│   │   │   │       ├── PlayPrevButton.svelte
│   │   │   │       ├── PlayToggleButton.svelte
│   │   │   │       ├── PlayTogglePillButton.svelte
│   │   │   │       ├── PlayerFavoriteButton.svelte
│   │   │   │       ├── RepeatButton.svelte
│   │   │   │       └── ShuffleButton.svelte
│   │   │   ├── playlists/
│   │   │   │   ├── PlaylistListContainer.svelte
│   │   │   │   └── PlaylistListItem.svelte
│   │   │   ├── snackbar/
│   │   │   │   ├── Snackbar.svelte
│   │   │   │   ├── SnackbarRenderer.svelte
│   │   │   │   ├── snackbar.ts
│   │   │   │   └── store.svelte.ts
│   │   │   └── tracks/
│   │   │       ├── TrackListItem.svelte
│   │   │       ├── TracksListContainer.svelte
│   │   │       ├── selection.svelte.ts
│   │   │       ├── use-track-drag-controller.svelte.ts
│   │   │       ├── use-track-menu-items.ts
│   │   │       └── use-track-selection-controller.svelte.ts
│   │   ├── db/
│   │   │   ├── database.ts
│   │   │   ├── events.ts
│   │   │   ├── lock-database.ts
│   │   │   └── query/
│   │   │       ├── base-query.svelte.ts
│   │   │       ├── inline-query.svelte.ts
│   │   │       ├── page-query.svelte.ts
│   │   │       └── query.ts
│   │   ├── helpers/
│   │   │   ├── __tests__/
│   │   │   │   └── serial-queue.test.ts
│   │   │   ├── animations.ts
│   │   │   ├── audio.ts
│   │   │   ├── create-managed-artwork.svelte.ts
│   │   │   ├── debounced.svelte.ts
│   │   │   ├── file-system.ts
│   │   │   ├── focus.ts
│   │   │   ├── input.ts
│   │   │   ├── persist.svelte.ts
│   │   │   ├── register-sw.ts
│   │   │   ├── serial-queue.ts
│   │   │   ├── test-helpers.ts
│   │   │   ├── ui-action.ts
│   │   │   ├── utils/
│   │   │   │   ├── array.ts
│   │   │   │   ├── assign.ts
│   │   │   │   ├── clamp.ts
│   │   │   │   ├── debounce.ts
│   │   │   │   ├── format-duration.ts
│   │   │   │   ├── integers.ts
│   │   │   │   ├── navigate.ts
│   │   │   │   ├── text.ts
│   │   │   │   ├── throttle.ts
│   │   │   │   ├── ua.ts
│   │   │   │   └── wait.ts
│   │   │   └── virtualizer.svelte.ts
│   │   ├── layout-bottom-bar.svelte.ts
│   │   ├── library/
│   │   │   ├── __tests__/
│   │   │   │   ├── play-history.test.ts
│   │   │   │   ├── playlists.test.ts
│   │   │   │   └── remove.test.ts
│   │   │   ├── get/
│   │   │   │   ├── __tests__/
│   │   │   │   │   └── value.test.ts
│   │   │   │   ├── ids-queries.ts
│   │   │   │   ├── ids.ts
│   │   │   │   ├── value-queries.ts
│   │   │   │   └── value.ts
│   │   │   ├── play-history-actions.ts
│   │   │   ├── playlists-actions.ts
│   │   │   ├── remove.ts
│   │   │   ├── scan-actions/
│   │   │   │   ├── directories.ts
│   │   │   │   ├── scan-tracks.ts
│   │   │   │   └── scanner/
│   │   │   │       ├── actions.ts
│   │   │   │       ├── import-track.ts
│   │   │   │       ├── parse/
│   │   │   │       │   ├── format-artwork.ts
│   │   │   │       │   ├── image-primary-color.ts
│   │   │   │       │   └── parse-track.ts
│   │   │   │       ├── start.ts
│   │   │   │       ├── types.ts
│   │   │   │       └── worker.ts
│   │   │   ├── tracks-queries.ts
│   │   │   └── types.ts
│   │   ├── menu-actions/
│   │   │   └── playlists.ts
│   │   ├── stores/
│   │   │   ├── dialogs/
│   │   │   │   ├── store.svelte.ts
│   │   │   │   └── use-store.ts
│   │   │   ├── main/
│   │   │   │   ├── store.svelte.ts
│   │   │   │   └── use-store.ts
│   │   │   └── player/
│   │   │       ├── __test__/
│   │   │       │   ├── audio-loader.test.ts
│   │   │       │   ├── equalizer.test.ts
│   │   │       │   ├── player.svelte.test.ts
│   │   │       │   └── queue.test.ts
│   │   │       ├── audio-loader.svelte.ts
│   │   │       ├── equalizer.svelte.ts
│   │   │       ├── player.svelte.ts
│   │   │       ├── queue.svelte.ts
│   │   │       └── use-store.ts
│   │   ├── theme.ts
│   │   └── view-transitions.svelte.ts
│   ├── params/
│   │   └── libraryEntities.ts
│   ├── routes/
│   │   ├── (app)/
│   │   │   ├── (plain)/
│   │   │   │   ├── +layout.svelte
│   │   │   │   ├── about/
│   │   │   │   │   ├── +page.svelte
│   │   │   │   │   └── +page.ts
│   │   │   │   └── settings/
│   │   │   │       ├── +page.svelte
│   │   │   │       ├── +page.ts
│   │   │   │       └── components/
│   │   │   │           ├── DirectoriesList.svelte
│   │   │   │           ├── InstallAppBanner.svelte
│   │   │   │           └── MissingFsApiBanner.svelte
│   │   │   ├── +layout.svelte
│   │   │   ├── layout/
│   │   │   │   ├── app-install-prompt.ts
│   │   │   │   ├── setup-directories-permission-prompt.svelte.ts
│   │   │   │   └── setup-theme.svelte.ts
│   │   │   ├── library/
│   │   │   │   └── [[slug=libraryEntities]]/
│   │   │   │       ├── +layout.svelte
│   │   │   │       ├── +layout.ts
│   │   │   │       ├── +page.svelte
│   │   │   │       ├── Search.svelte
│   │   │   │       ├── [uuid]/
│   │   │   │       │   ├── +page.svelte
│   │   │   │       │   └── +page.ts
│   │   │   │       ├── config.ts
│   │   │   │       └── store.svelte.ts
│   │   │   └── player/
│   │   │       ├── +layout.svelte
│   │   │       ├── +layout.ts
│   │   │       ├── +page.ts
│   │   │       ├── history/
│   │   │       │   └── +page.ts
│   │   │       ├── layout-props.ts
│   │   │       └── queue/
│   │   │           └── +page.ts
│   │   ├── (assets)/
│   │   │   ├── icons/
│   │   │   │   └── icon.server.ts
│   │   │   └── manifest.webmanifest/
│   │   │       └── +server.ts
│   │   ├── (marketing)/
│   │   │   ├── +page.svelte
│   │   │   ├── +page.ts
│   │   │   ├── AGENTS.md
│   │   │   ├── TONE_OF_VOICE.md
│   │   │   ├── assets/
│   │   │   │   ├── hero.avif
│   │   │   │   └── marketing-equalizer-preview.avif
│   │   │   └── components/
│   │   │       ├── FeaturesSection.svelte
│   │   │       ├── GettingStartedSection.svelte
│   │   │       ├── HeroSection.svelte
│   │   │       ├── HowItWorksSection.svelte
│   │   │       ├── Section.svelte
│   │   │       └── SoundControlsSection.svelte
│   │   ├── +error.svelte
│   │   ├── +layout.svelte
│   │   └── +layout.ts
│   ├── server/
│   │   └── theme-colors.ts
│   ├── service-worker.ts
│   └── theme-colors.css
├── static/
│   └── supported-browser-check.js
├── svelte.config.js
├── tsconfig.base.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── vitest.config.ts
Download .txt
SYMBOL INDEX (297 symbols across 70 files)

FILE: lib/vite-image-metadata.ts
  function imageMetadataPlugin (line 12) | function imageMetadataPlugin(): Plugin {

FILE: lib/vite-log-chunk-size.ts
  method writeBundle (line 10) | writeBundle() {

FILE: scripts/check-translations.ts
  type Messages (line 3) | type Messages = Record<string, string>
  type LocaleIssues (line 5) | interface LocaleIssues {
  type LocaleReport (line 10) | interface LocaleReport {
  type BaseMessageWithParams (line 15) | interface BaseMessageWithParams {

FILE: src/app.d.ts
  type PageData (line 13) | interface PageData {
  type ClassValue (line 22) | type ClassValue = ClassValueInternal
  type Snippet (line 23) | type Snippet<Parameters extends unknown[] = []> = SnippetInternal<Parame...
  type Navigator (line 25) | interface Navigator {
  type BeforeInstallPromptEvent (line 41) | interface BeforeInstallPromptEvent extends Event {
  type WindowEventMap (line 65) | interface WindowEventMap {
  type GoatCounter (line 69) | interface GoatCounter {
  type Window (line 73) | interface Window {
  type MouseEvent (line 81) | interface MouseEvent {

FILE: src/lib/app-metadata.ts
  constant APP_NAME_EN (line 1) | const APP_NAME_EN = 'Snae Player'
  constant APP_NAME_SHORT_EN (line 2) | const APP_NAME_SHORT_EN = 'Snae'
  constant APP_DESCRIPTION_EN (line 3) | const APP_DESCRIPTION_EN =

FILE: src/lib/attachments/ripple.ts
  constant FADE_DURATION (line 6) | const FADE_DURATION = 180
  constant SCALE_DURATION (line 7) | const SCALE_DURATION = 400
  type RippleOptions (line 121) | interface RippleOptions {

FILE: src/lib/components/global-dialogs/dialogs.ts
  type ComponentWithOpenProp (line 10) | type ComponentWithOpenProp = Component<{ open: DialogOpenAccessor<any> }>
  constant APP_DIALOGS_COMPONENTS_MAP (line 12) | const APP_DIALOGS_COMPONENTS_MAP = {
  type AppDialogKey (line 20) | type AppDialogKey = keyof typeof APP_DIALOGS_COMPONENTS_MAP
  constant APP_DIALOGS_KEYS (line 22) | const APP_DIALOGS_KEYS = Object.keys(APP_DIALOGS_COMPONENTS_MAP) as AppD...

FILE: src/lib/components/icon/icon-paths.server.ts
  constant ICON_PATHS (line 4) | const ICON_PATHS = {
  type IconType (line 79) | type IconType = keyof typeof ICON_PATHS

FILE: src/lib/components/menu/positioning.ts
  type MenuPositioning (line 35) | interface MenuPositioning extends MenuPosition {

FILE: src/lib/components/menu/types.ts
  type MenuPosition (line 1) | interface MenuPosition {
  type MenuAlignment (line 6) | interface MenuAlignment {
  type MenuAnchorOptions (line 11) | interface MenuAnchorOptions {
  type MenuPositionOptions (line 16) | interface MenuPositionOptions {
  type MenuSize (line 21) | interface MenuSize {
  type MenuOptions (line 27) | type MenuOptions = (MenuAnchorOptions | MenuPositionOptions) & MenuSize
  type MenuItem (line 30) | interface MenuItem {

FILE: src/lib/components/snackbar/snackbar.ts
  type SnackbarOptions (line 4) | type SnackbarOptions<T = unknown> = SnackbarData<T>

FILE: src/lib/components/tracks/selection.svelte.ts
  class SelectionTracker (line 3) | class SelectionTracker {
    method selectedIds (line 7) | get selectedIds() {
    method selectionEnabled (line 11) | get selectionEnabled() {
    method enterSelectionMode (line 17) | enterSelectionMode() {
    method toggle (line 21) | toggle(id: number, index: number) {
    method select (line 36) | select(id: number, index: number) {
    method unselect (line 42) | unselect(id: number) {
    method selectMany (line 50) | selectMany(ids: readonly number[]) {
    method unselectMany (line 58) | unselectMany(ids: readonly number[]) {
    method setHoverAnchor (line 70) | setHoverAnchor(index: number) {
    method clearHoverAnchor (line 77) | clearHoverAnchor() {
    method has (line 83) | has(id: number) {
    method clear (line 87) | clear() {
    method size (line 93) | get size() {

FILE: src/lib/components/tracks/use-track-drag-controller.svelte.ts
  constant EDGE_THRESHOLD (line 3) | const EDGE_THRESHOLD = 84
  constant MAX_SCROLL_STEP (line 4) | const MAX_SCROLL_STEP = 30
  type DragState (line 6) | interface DragState {
  type UseTrackDragControllerOptions (line 16) | interface UseTrackDragControllerOptions {
  method drag (line 189) | get drag() {

FILE: src/lib/components/tracks/use-track-menu-items.ts
  type PredefinedTrackMenuItemOption (line 8) | type PredefinedTrackMenuItemOption =
  type PredefinedMenuItem (line 17) | interface PredefinedMenuItem extends MenuItem {
  type FalsyValue (line 21) | type FalsyValue = false | undefined | null | ''
  type UnfilteredPredefinedMenuItem (line 23) | type UnfilteredPredefinedMenuItem = PredefinedMenuItem | FalsyValue

FILE: src/lib/components/tracks/use-track-selection-controller.svelte.ts
  type SelectionInteractionState (line 4) | interface SelectionInteractionState {
  type UseTrackSelectionControllerOptions (line 9) | interface UseTrackSelectionControllerOptions {
  type HandleItemClickOptions (line 13) | interface HandleItemClickOptions {
  method selectionEnabled (line 160) | get selectionEnabled() {
  method selectedIds (line 163) | get selectedIds() {
  method size (line 166) | get size() {

FILE: src/lib/db/database.ts
  type AppDB (line 14) | interface AppDB extends DBSchema {
  type AppStoreNames (line 93) | type AppStoreNames = StoreNames<AppDB>
  type AppIndexNames (line 94) | type AppIndexNames<Store extends AppStoreNames> = IndexNames<AppDB, Store>
  method upgrade (line 117) | async upgrade(db, oldVersion, _newVersion, tx) {
  type AppIDBDatabase (line 218) | type AppIDBDatabase = IDBPDatabase<AppDB>
  type DbKey (line 244) | type DbKey<Name extends AppStoreNames> = AppDB[Name]['key']
  type DbValue (line 245) | type DbValue<Name extends AppStoreNames> = AppDB[Name]['value']

FILE: src/lib/db/events.ts
  type DbBaseChange (line 3) | type DbBaseChange<
  type DbStandardChange (line 17) | type DbStandardChange<
  type DatabaseChangeDetails (line 25) | type DatabaseChangeDetails = {
  type DatabaseChangeDetailsList (line 29) | type DatabaseChangeDetailsList = readonly DatabaseChangeDetails[]
  type Listener (line 36) | type Listener = (changes: readonly DatabaseChangeDetails[]) => void

FILE: src/lib/db/query/base-query.svelte.ts
  type QueryStatus (line 4) | type QueryStatus = 'loading' | 'loaded' | 'error'
  type QueryBaseResult (line 6) | interface QueryBaseResult {
  type QueryLoadedResult (line 10) | interface QueryLoadedResult<Result> {
  type QueryLoadingResult (line 17) | interface QueryLoadingResult<Result> {
  type QueryErrorResult (line 24) | interface QueryErrorResult<Result> {
  type QueryResult (line 31) | type QueryResult<Result> = QueryBaseResult &
  type QueryMutate (line 34) | type QueryMutate<Result> = (value: Result | ((prev: Result | undefined) ...
  type DbChangeActions (line 36) | interface DbChangeActions<Result> {
  type DatabaseChangeHandler (line 41) | type DatabaseChangeHandler<Result> = (
  type QueryKeyPrimitiveValue (line 46) | type QueryKeyPrimitiveValue = number | string | boolean
  type QueryKey (line 47) | type QueryKey = QueryKeyPrimitiveValue | QueryKeyPrimitiveValue[]
  type QueryBaseOptions (line 52) | interface QueryBaseOptions<K extends QueryKey, Result> {
  type QueryStateInternal (line 58) | type QueryStateInternal<Result> = Omit<QueryResult<Result>, 'loading'>
  class QueryImpl (line 60) | class QueryImpl<K extends QueryKey, Result> {
    method constructor (line 73) | constructor(options: QueryBaseOptions<K, Result>) {
    method #getKey (line 77) | #getKey() {
  class QueryResultBox (line 173) | class QueryResultBox<Result> {
    method constructor (line 176) | constructor(state: QueryStateInternal<Result>) {
    method value (line 180) | get value() {
    method error (line 184) | get error() {
    method status (line 188) | get status() {
    method loading (line 192) | get loading() {

FILE: src/lib/db/query/inline-query.svelte.ts
  type InlineQueryOptions (line 6) | interface InlineQueryOptions<K extends QueryKey, Result> {

FILE: src/lib/db/query/page-query.svelte.ts
  type PageQueryResult (line 12) | type PageQueryResult<Result> = QueryResult<Result> & {
  type PageQueryOptions (line 15) | type PageQueryOptions<K extends QueryKey, Result> = QueryBaseOptions<K, ...
  class PageQueryResultBox (line 19) | class PageQueryResultBox<Result> extends QueryResultBox<Result> {
    method constructor (line 24) | constructor(state: QueryStateInternal<Result>, setupListeners: () => v...
    method [pageQueryHydrateSymbol] (line 29) | [pageQueryHydrateSymbol](): void {

FILE: src/lib/helpers/animations.ts
  type SequenceKeyframeAnimationOptions (line 7) | interface SequenceKeyframeAnimationOptions extends KeyframeAnimationOpti...
  type AnimationSequence (line 12) | type AnimationSequence = [
  type AnimationSequenceOptions (line 18) | interface AnimationSequenceOptions {

FILE: src/lib/helpers/create-managed-artwork.svelte.ts
  class Artwork (line 1) | class Artwork {
    method createRefId (line 4) | static createRefId() {
    method constructor (line 17) | constructor(image: Blob) {

FILE: src/lib/helpers/debounced.svelte.ts
  type Getter (line 3) | type Getter<T> = () => T
  class Debounced (line 5) | class Debounced<T> {
    method current (line 8) | get current(): T {
    method constructor (line 12) | constructor(getter: Getter<T>, delay: number) {

FILE: src/lib/helpers/file-system.ts
  type FileEntity (line 5) | type FileEntity = File | FileSystemFileHandle

FILE: src/lib/helpers/input.ts
  constant TEXT_INPUT_TYPES (line 1) | const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'pas...

FILE: src/lib/helpers/register-sw.ts
  type RegisterSwOptions (line 16) | interface RegisterSwOptions {

FILE: src/lib/helpers/serial-queue.ts
  class SerialQueue (line 2) | class SerialQueue {
    method enqueue (line 5) | enqueue(promiseFn: () => Promise<void>): Promise<void> {
    method drain (line 12) | drain(): Promise<void> {

FILE: src/lib/helpers/test-helpers.ts
  function expectToBeDefined (line 13) | function expectToBeDefined<T>(value: T | undefined): asserts value is T {

FILE: src/lib/helpers/utils/assign.ts
  type Impossible (line 2) | type Impossible<K extends keyof any> = {

FILE: src/lib/helpers/virtualizer.svelte.ts
  function createVirtualizerBase (line 5) | function createVirtualizerBase<

FILE: src/lib/layout-bottom-bar.svelte.ts
  type BottomBarState (line 4) | interface BottomBarState {
  method bottomBar (line 20) | get bottomBar(): BottomBarState['bottomBar'] {
  method abovePlayer (line 23) | get abovePlayer(): Snippet[] {

FILE: src/lib/library/get/ids-queries.ts
  type LibraryItemKeysPageQueryOptions (line 77) | type LibraryItemKeysPageQueryOptions<K extends QueryKey> = Omit<

FILE: src/lib/library/get/ids.ts
  type SortOrder (line 5) | type SortOrder = 'asc' | 'desc'
  type LibraryItemSortKey (line 6) | type LibraryItemSortKey<Store extends LibraryStoreName> = Exclude<
  type GetLibraryItemIdsOptions (line 11) | interface GetLibraryItemIdsOptions<Store extends LibraryStoreName> {
  type GetLibraryItemIdsIndex (line 19) | type GetLibraryItemIdsIndex<Store extends LibraryStoreName> = IDBPIndex<

FILE: src/lib/library/get/value-queries.ts
  type LibraryValueQueryOptions (line 7) | interface LibraryValueQueryOptions<AllowEmpty extends boolean = false> {
  type LibraryItemQuery (line 27) | type LibraryItemQuery<Store extends LibraryStoreName> = ReturnType<typeo...

FILE: src/lib/library/get/value.ts
  type CacheKey (line 7) | type CacheKey<Store extends LibraryStoreName> = `${Store}:${string}`
  type QueryConfig (line 14) | interface QueryConfig<Result> {
  type TrackData (line 42) | interface TrackData extends Track {
  type AlbumData (line 109) | interface AlbumData extends Album {
  type ArtistData (line 117) | interface ArtistData extends Artist {
  type PlaylistData (line 126) | interface PlaylistData extends Playlist {
  type LibraryValueMap (line 150) | interface LibraryValueMap {
  type LibraryValue (line 157) | type LibraryValue<Store extends LibraryStoreName = LibraryStoreName> = L...
  type LibraryConfigMap (line 159) | type LibraryConfigMap = {
  type LibraryCachedValue (line 170) | type LibraryCachedValue<Store extends LibraryStoreName = LibraryStoreNam...
  class LibraryValueCache (line 174) | class LibraryValueCache {
    method get (line 179) | get<Store extends LibraryStoreName>(key: CacheKey<Store>) {
    method set (line 183) | set<Store extends LibraryStoreName>(
    method delete (line 194) | delete<Store extends LibraryStoreName>(key: CacheKey<Store>) {
    method clear (line 198) | clear() {
  class LibraryValueNotFoundError (line 240) | class LibraryValueNotFoundError extends Error {
    method constructor (line 241) | constructor(cacheKey: CacheKey<LibraryStoreName>) {
  type GetLibraryValueResult (line 284) | type GetLibraryValueResult<

FILE: src/lib/library/play-history-actions.ts
  constant PLAY_HISTORY_LIMIT (line 6) | const PLAY_HISTORY_LIMIT = 100

FILE: src/lib/library/playlists-actions.ts
  type UpdatePlaylistOptions (line 50) | interface UpdatePlaylistOptions {
  type DbPlaylistEntriesStore (line 122) | type DbPlaylistEntriesStore = IDBPObjectStore<
  type AddTracksToPlaylistOptions (line 137) | interface AddTracksToPlaylistOptions {
  type RemoveTracksFromPlaylistOptions (line 173) | interface RemoveTracksFromPlaylistOptions {
  type BatchModifyPlaylistSelectionOptions (line 204) | interface BatchModifyPlaylistSelectionOptions {

FILE: src/lib/library/remove.ts
  type TrackOperationsTransaction (line 6) | type TrackOperationsTransaction = IDBPTransaction<

FILE: src/lib/library/scan-actions/directories.ts
  type DirectoryStatus (line 8) | interface DirectoryStatus {

FILE: src/lib/library/scan-actions/scanner/actions.ts
  type TrackEnqueueOptions (line 13) | interface TrackEnqueueOptions {
  class TrackProcessor (line 31) | class TrackProcessor {
    method constructor (line 38) | constructor(tracker: StatusTracker, onImportSuccess?: (trackId: number...
    method parseAndEnqueue (line 43) | async parseAndEnqueue(options: TrackEnqueueOptions) {
    method drain (line 80) | async drain(): Promise<void> {
  class StatusTracker (line 86) | class StatusTracker {
    method constructor (line 98) | constructor(total: number, timeId: string) {

FILE: src/lib/library/scan-actions/scanner/import-track.ts
  type ImportTrackTx (line 12) | type ImportTrackTx = IDBPTransaction<

FILE: src/lib/library/scan-actions/scanner/parse/format-artwork.ts
  type ArtworkRelatedData (line 25) | interface ArtworkRelatedData {

FILE: src/lib/library/scan-actions/scanner/parse/image-primary-color.ts
  constant SHIFT (line 1) | const SHIFT = 3 // quantize 8-bit -> 5-bit
  constant BINS (line 2) | const BINS = 32 * 32 * 32 // 5-bit bins
  function rgb2hsv (line 15) | function rgb2hsv(r: number, g: number, b: number) {
  function getPrimaryColor (line 47) | function getPrimaryColor(pixels: Uint8ClampedArray, width: number, heigh...

FILE: src/lib/library/scan-actions/scanner/parse/parse-track.ts
  constant FILE_SIZE_LIMIT_300MB (line 5) | const FILE_SIZE_LIMIT_300MB = 300 * 1024 * 1024

FILE: src/lib/library/scan-actions/scanner/start.ts
  type TrackParsedFn (line 12) | type TrackParsedFn = (totalParsedCount: number) => void

FILE: src/lib/library/scan-actions/scanner/types.ts
  type TracksScanResult (line 3) | interface TracksScanResult {
  type TracksScanMessage (line 12) | interface TracksScanMessage {
  type TracksScanOptions (line 18) | type TracksScanOptions =

FILE: src/lib/library/types.ts
  type LibraryStoreName (line 3) | type LibraryStoreName = 'tracks' | 'albums' | 'artists' | 'playlists'
  constant LEGACY_NO_NATIVE_DIRECTORY (line 11) | const LEGACY_NO_NATIVE_DIRECTORY = -1
  constant FAVORITE_PLAYLIST_ID (line 14) | const FAVORITE_PLAYLIST_ID = -1
  constant FAVORITE_PLAYLIST_UUID (line 15) | const FAVORITE_PLAYLIST_UUID = 'favorites'
  constant UNKNOWN_ITEM (line 21) | const UNKNOWN_ITEM = '~\0unknown'
  type UnknownItem (line 23) | type UnknownItem = typeof UNKNOWN_ITEM
  type StringOrUnknownItem (line 25) | type StringOrUnknownItem = (string & {}) | UnknownItem
  type BaseMusicItem (line 27) | interface BaseMusicItem {
  type ParsedTrackData (line 32) | interface ParsedTrackData {
  type UnknownTrack (line 52) | interface UnknownTrack extends ParsedTrackData {
  type Track (line 60) | interface Track extends BaseMusicItem, UnknownTrack {}
  type Album (line 62) | interface Album extends BaseMusicItem {
  type Artist (line 69) | interface Artist extends BaseMusicItem {
  type Playlist (line 73) | interface Playlist extends BaseMusicItem {
  type PlaylistEntry (line 79) | interface PlaylistEntry {
  type PlayHistoryEntry (line 86) | interface PlayHistoryEntry {
  type Directory (line 92) | interface Directory {

FILE: src/lib/stores/dialogs/store.svelte.ts
  type DialogOpenProp (line 8) | type DialogOpenProp<K extends AppDialogKey> = ComponentProps<
  type StateMap (line 12) | type StateMap = {
  type DialogState (line 16) | type DialogState<K extends AppDialogKey> = NonNullable<StateMap[K]>
  type BooleanProps (line 18) | type BooleanProps = {
  class DialogsStore (line 22) | class DialogsStore {
    method getAccessor (line 27) | getAccessor(key: AppDialogKey): DialogOpenAccessor<any> {
    method openDialog (line 38) | openDialog<K extends AppDialogKey>(dialog: K, open: DialogState<K>) {
    method closeDialog (line 42) | closeDialog<K extends AppDialogKey>(dialog: K) {

FILE: src/lib/stores/main/store.svelte.ts
  type AppTheme (line 7) | type AppTheme = 'light' | 'dark'
  type AppThemeOption (line 8) | type AppThemeOption = AppTheme | 'auto'
  type AppMotion (line 10) | type AppMotion = 'normal' | 'reduced'
  type AppMotionOption (line 11) | type AppMotionOption = AppMotion | 'auto'
  class MainStore (line 16) | class MainStore {
    method isThemeDark (line 21) | get isThemeDark(): boolean {
    method isReducedMotion (line 31) | get isReducedMotion(): boolean {
    method constructor (line 52) | constructor() {

FILE: src/lib/stores/player/__test__/equalizer.test.ts
  type MockFilter (line 8) | interface MockFilter {
  type MockAudioContext (line 16) | interface MockAudioContext {
  class AudioContextMock (line 31) | class AudioContextMock implements MockAudioContext {
    method constructor (line 55) | constructor() {

FILE: src/lib/stores/player/__test__/player.svelte.test.ts
  method value (line 27) | get value() {
  method error (line 30) | get error() {
  method status (line 33) | get status() {
  method loading (line 36) | get loading() {
  method init (line 50) | init() {}
  method resumeContext (line 51) | resumeContext() {
  method setBand (line 54) | setBand() {}
  method applyPreset (line 55) | applyPreset() {}
  method reset (line 56) | reset() {}
  class MediaMetadataMock (line 74) | class MediaMetadataMock {}
  class MockAudio (line 76) | class MockAudio {
  class AudioConstructor (line 167) | class AudioConstructor extends MockAudio {
    method constructor (line 168) | constructor() {
    method constructor (line 318) | constructor() {
  class AudioConstructor (line 317) | class AudioConstructor extends MockAudio {
    method constructor (line 168) | constructor() {
    method constructor (line 318) | constructor() {

FILE: src/lib/stores/player/audio-loader.svelte.ts
  class AudioLoader (line 82) | class AudioLoader {
    method constructor (line 89) | constructor(onSrc: (src: string | null) => void) {

FILE: src/lib/stores/player/equalizer.svelte.ts
  constant EQ_BANDS (line 3) | const EQ_BANDS = [
  type BuiltinEqPresetKey (line 16) | type BuiltinEqPresetKey =
  constant EQ_PRESET_GAINS (line 27) | const EQ_PRESET_GAINS: Record<BuiltinEqPresetKey, readonly number[]> = {
  constant EQ_MIN_GAIN (line 39) | const EQ_MIN_GAIN = -12
  constant EQ_MAX_GAIN (line 40) | const EQ_MAX_GAIN = 12
  class EqualizerStore (line 42) | class EqualizerStore {
    method constructor (line 51) | constructor(audio: HTMLAudioElement) {

FILE: src/lib/stores/player/player.svelte.ts
  type PlayerRepeat (line 16) | type PlayerRepeat = 'none' | 'one' | 'all'
  constant PLAYER_PLAYBACK_RATE_MIN (line 18) | const PLAYER_PLAYBACK_RATE_MIN = 0.5
  constant PLAYER_PLAYBACK_RATE_MAX (line 19) | const PLAYER_PLAYBACK_RATE_MAX = 2
  class PlayerStore (line 21) | class PlayerStore {
    method shuffle (line 39) | get shuffle(): boolean {
    method itemsIds (line 43) | get itemsIds(): readonly number[] {
    method activeTrackIndex (line 47) | get activeTrackIndex(): number {
    method isQueueEmpty (line 51) | get isQueueEmpty(): boolean {
    method volume (line 60) | get volume(): number {
    method volume (line 64) | set volume(value: number) {
    method constructor (line 78) | constructor() {

FILE: src/lib/stores/player/queue.svelte.ts
  type PlayTrackOptions (line 4) | interface PlayTrackOptions {
  class QueueStore (line 8) | class QueueStore {
    method activeTrackIndex (line 19) | get activeTrackIndex(): number {
    method activeTrackId (line 23) | get activeTrackId(): number | null {
    method isQueueEmpty (line 27) | get isQueueEmpty(): boolean {
    method constructor (line 31) | constructor() {

FILE: src/lib/theme.ts
  type PaletteToken (line 10) | type PaletteToken =
  type Tone (line 47) | type Tone = 'a1' | 'a2' | 'a3' | 'n1' | 'n2' | 'error'
  type PaletteTokenInput (line 48) | type PaletteTokenInput = readonly [tone: Tone, light: number, dark: number]
  type PaletteTokensInputMap (line 50) | type PaletteTokensInputMap = Record<PaletteToken, PaletteTokenInput>
  constant COLOR_TOKENS_GENERATION_MAP (line 52) | const COLOR_TOKENS_GENERATION_MAP: PaletteTokensInputMap = {
  constant COLOR_TOKENS_GENERATION_ENTRIES (line 90) | const COLOR_TOKENS_GENERATION_ENTRIES = Object.entries(COLOR_TOKENS_GENE...
  type TonalPalette (line 99) | interface TonalPalette {
  type ThemeEntry (line 103) | type ThemeEntry = [key: PaletteToken, hexValue: string]

FILE: src/lib/view-transitions.svelte.ts
  type AppViewTransitionType (line 8) | type AppViewTransitionType = 'regular' | 'player' | 'library' | 'disabled'
  type AppViewTransitionTypeMatcherResult (line 10) | type AppViewTransitionTypeMatcherResult = {
  type AppViewTransitionTypeMatcher (line 15) | type AppViewTransitionTypeMatcher = (
  type ViewTransitionReadyListener (line 29) | type ViewTransitionReadyListener = (

FILE: src/params/libraryEntities.ts
  type LibraryEntitiesSlug (line 2) | type LibraryEntitiesSlug = (typeof libraryEntitiesSlugs)[number]

FILE: src/routes/(app)/(plain)/settings/+page.ts
  type DirectoryWithCount (line 5) | type DirectoryWithCount = { count: number } & (
  type LoadResult (line 54) | interface LoadResult {

FILE: src/routes/(app)/layout/setup-directories-permission-prompt.svelte.ts
  type DirectoryNeedingPermission (line 3) | interface DirectoryNeedingPermission {
  type DirectoriesPermissionPromptSnackbarArg (line 8) | interface DirectoriesPermissionPromptSnackbarArg {

FILE: src/routes/(app)/library/[[slug=libraryEntities]]/+layout.ts
  type LoadDataResult (line 21) | type LoadDataResult<Slug extends LibraryStoreName> = {
  type LoadResult (line 68) | type LoadResult = LoadDataResult<LibraryStoreName> & {

FILE: src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.ts
  type DetailsSlug (line 14) | type DetailsSlug = Exclude<LibraryStoreName, 'tracks'>
  type TracksQueryRegularResult (line 42) | interface TracksQueryRegularResult {
  type PlaylistTrackItem (line 80) | interface PlaylistTrackItem {
  type PlaylistTracksQueryResult (line 85) | interface PlaylistTracksQueryResult {
  type LoadResult (line 128) | interface LoadResult {

FILE: src/routes/(app)/library/[[slug=libraryEntities]]/config.ts
  type LibrarySearchFn (line 5) | type LibrarySearchFn<Value> = (value: Value, searchTerm: string) => boolean
  type SortOption (line 7) | interface SortOption<Store extends LibraryStoreName> {
  type LibraryRouteConfig (line 12) | interface LibraryRouteConfig<Slug extends LibraryStoreName> {
  type LibraryRouteConfigsMap (line 103) | type LibraryRouteConfigsMap = {

FILE: src/routes/(app)/library/[[slug=libraryEntities]]/store.svelte.ts
  class LibraryStore (line 5) | class LibraryStore<Slug extends LibraryStoreName> {
    method constructor (line 12) | constructor(slug: Slug) {

FILE: src/routes/(app)/player/+layout.ts
  type LoadResult (line 7) | interface LoadResult {

FILE: src/routes/(app)/player/layout-props.ts
  type LayoutProps (line 20) | interface LayoutProps {

FILE: src/routes/+layout.ts
  method onNeedRefresh (line 27) | onNeedRefresh(update) {

FILE: src/server/theme-colors.ts
  constant THEME_PALLETTE_LIGHT (line 5) | const THEME_PALLETTE_LIGHT = {} as Record<PaletteToken, string>
  constant THEME_PALLETTE_DARK (line 8) | const THEME_PALLETTE_DARK = {} as Record<PaletteToken, string>

FILE: src/service-worker.ts
  constant CACHE (line 10) | const CACHE = `cache-${version}`
  constant ASSETS (line 11) | const ASSETS = [...build, ...files, ...prerendered, PUBLIC_FALLBACK_PAGE]
  function addFilesToCache (line 15) | async function addFilesToCache() {
  function deleteOldCaches (line 27) | async function deleteOldCaches() {
  function respond (line 59) | async function respond() {

FILE: vite.config.ts
  method config (line 83) | config(config) {
Condensed preview — 230 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (642K chars).
[
  {
    "path": ".env.example",
    "chars": 55,
    "preview": "PUBLIC_FALLBACK_PAGE=/200.html\nPUBLIC_GOAT_COUNTER_URL="
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 1786,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n---\n\n## ⚠️ Before cre"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 362,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: ❓ Questions & Support\n    url: https://github.com/minht11/local-mus"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 803,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n---\n\n## ⚠️"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 738,
    "preview": "name: CI\n\non:\n  pull_request:\n  push:\n    branches:\n      - main\n\nenv:\n  PUBLIC_FALLBACK_PAGE: ${{ vars.PUBLIC_FALLBACK_"
  },
  {
    "path": ".gitignore",
    "chars": 121,
    "preview": ".DS_Store\nnode_modules\nbuild\n.generated\n.env\n!.env.example\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\ncoverag"
  },
  {
    "path": ".prettierignore",
    "chars": 236,
    "preview": ".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\npn"
  },
  {
    "path": ".prettierrc",
    "chars": 291,
    "preview": "{\n\t\"useTabs\": true,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"all\",\n\t\"semi\": false,\n\t\"printWidth\": 100,\n\t\"plugins\": [\"pre"
  },
  {
    "path": ".vscode/extensions.json",
    "chars": 95,
    "preview": "{\n\t\"recommendations\": [\"bradlc.vscode-tailwindcss\", \"biomejs.biome\", \"svelte.svelte-vscode\"]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 822,
    "preview": "{\n\t\"svelte.plugin.svelte.compilerWarnings\": {\n\t\t\"missing-declaration\": \"ignore\"\n\t},\n\t\"editor.codeActionsOnSave\": {\n\t\t\"so"
  },
  {
    "path": "AGENTS.md",
    "chars": 17299,
    "preview": "# Agent instructions\n\n## Project Overview\n\n**Snae Player** is a privacy-first local music PWA that runs entirely in the "
  },
  {
    "path": "LICENSE.txt",
    "chars": 1094,
    "preview": "MIT License\r\n\r\nCopyright (c) 2019 Justinas Delinda\r\n\r\nPermission is hereby granted, free of charge, to any person obtain"
  },
  {
    "path": "README.md",
    "chars": 1225,
    "preview": "# Snae Player\n\n**[snaeplayer.com](https://snaeplayer.com)** - Local music player in the browser.\n\nPlay audio files store"
  },
  {
    "path": "biome.jsonc",
    "chars": 5737,
    "preview": "{\n\t\"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n\t\"vcs\": {\n\t\t\"enabled\": true,\n\t\t\"clientKind\": \"g"
  },
  {
    "path": "knip.json",
    "chars": 393,
    "preview": "{\n\t\"$schema\": \"https://unpkg.com/knip@6/schema.json\",\n\t\"tags\": [\"-lintignore\"],\n\t\"entry\": [\n\t\t\"src/routes/**/*.{svelte,t"
  },
  {
    "path": "lib/vite-image-metadata.ts",
    "chars": 1026,
    "preview": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { imageSizeFromFile } from 'image-size/fromFile'\nimport typ"
  },
  {
    "path": "lib/vite-log-chunk-size.ts",
    "chars": 1327,
    "preview": "import { readdirSync, statSync } from 'node:fs'\nimport path from 'node:path'\nimport type { Plugin } from 'vite'\n\n/** @pu"
  },
  {
    "path": "messages/de.json",
    "chars": 9363,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"Über\",\n\t\"aboutHomepage\": \"Webseite\",\n\t\"abou"
  },
  {
    "path": "messages/en.json",
    "chars": 8488,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"About\",\n\t\"aboutHomepage\": \"Homepage\",\n\t\"abo"
  },
  {
    "path": "messages/fr.json",
    "chars": 9464,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\t\"about\": \"À propos\",\n\t\"aboutHomepage\": \"Page d’accueil"
  },
  {
    "path": "messages/lt.json",
    "chars": 9017,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"Apie\",\n\t\"aboutHomepage\": \"Namai\",\n\t\"aboutJo"
  },
  {
    "path": "messages/zh-CN.json",
    "chars": 6379,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"关于\",\n\t\"aboutHomepage\": \"主页\",\n\t\"aboutJoinDis"
  },
  {
    "path": "messages/zh-TW.json",
    "chars": 6393,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n\n\t\"about\": \"關於\",\n\t\"aboutHomepage\": \"首頁\",\n\t\"aboutJoinDis"
  },
  {
    "path": "netlify.toml",
    "chars": 752,
    "preview": "[build]\npublish = \"build/\"\ncommand = \"pnpm run build\"\n\n[build.environment]\nNODE_VERSION = \"24.15.0\"\n\n# V1 version of the"
  },
  {
    "path": "package.json",
    "chars": 1666,
    "preview": "{\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\"sc"
  },
  {
    "path": "patches/@material__material-color-utilities.patch",
    "chars": 7957,
    "preview": "diff --git a/dynamiccolor/color_spec_2025.js b/dynamiccolor/color_spec_2025.js\nindex 8bef961c7c6127c028b98ee3305270be524"
  },
  {
    "path": "pnpm-workspace.yaml",
    "chars": 203,
    "preview": "engineStrict: true\n\nallowBuilds:\n  '@biomejs/biome': false\n  '@tailwindcss/oxide': false\n\npatchedDependencies:\n  '@mater"
  },
  {
    "path": "project.inlang/settings.json",
    "chars": 416,
    "preview": "{\n\t\"$schema\": \"https://inlang.com/schema/project-settings\",\n\t\"baseLocale\": \"en\",\n\t\"locales\": [\"en\", \"lt\", \"de\", \"fr\", \"z"
  },
  {
    "path": "scripts/check-translations.ts",
    "chars": 2775,
    "preview": "import projectSettings from '../project.inlang/settings.json' with { type: 'json' }\n\ntype Messages = Record<string, stri"
  },
  {
    "path": "scripts/gen-color-theme.ts",
    "chars": 946,
    "preview": "import { writeFileSync } from 'node:fs'\nimport {\n\targbFromHex,\n\t// biome-ignore lint/style/noRestrictedImports: Used for"
  },
  {
    "path": "src/ambient.d.ts",
    "chars": 130,
    "preview": "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 m"
  },
  {
    "path": "src/app.css",
    "chars": 8147,
    "preview": "@import 'tailwindcss';\n@import './theme-colors.css';\n\n/* We don't use these classes */\n@source not inline('container');\n"
  },
  {
    "path": "src/app.d.ts",
    "chars": 2529,
    "preview": "import type { Snippet as SnippetInternal } from 'svelte'\nimport type { ClassValue as ClassValueInternal } from 'svelte/e"
  },
  {
    "path": "src/app.html",
    "chars": 2077,
    "preview": "<!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<m"
  },
  {
    "path": "src/hooks.server.ts",
    "chars": 2269,
    "preview": "import type { Handle } from '@sveltejs/kit'\nimport { APP_DESCRIPTION_EN } from '$lib/app-metadata.ts'\nimport { ICON_PATH"
  },
  {
    "path": "src/lib/app-metadata.ts",
    "chars": 245,
    "preview": "export const APP_NAME_EN = 'Snae Player'\nexport const APP_NAME_SHORT_EN = 'Snae'\nexport const APP_DESCRIPTION_EN =\n\t'Pla"
  },
  {
    "path": "src/lib/attachments/ripple.ts",
    "chars": 3239,
    "preview": "import type { Attachment } from 'svelte/attachments'\nimport { on } from 'svelte/events'\nimport { assign } from '$lib/hel"
  },
  {
    "path": "src/lib/attachments/tooltip.ts",
    "chars": 2744,
    "preview": "import type { Attachment } from 'svelte/attachments'\nimport { on } from 'svelte/events'\nimport { browser } from '$app/en"
  },
  {
    "path": "src/lib/components/AlbumsListContainer.svelte",
    "chars": 551,
    "preview": "<script lang=\"ts\">\n\timport { formatArtists, formatNameOrUnknown } from '$lib/helpers/utils/text.ts'\n\timport LibraryGridL"
  },
  {
    "path": "src/lib/components/ArtistListContainer.svelte",
    "chars": 473,
    "preview": "<script lang=\"ts\">\n\timport LibraryGridListContainer from '$lib/components/library-grid/LibraryGridListContainer.svelte'\n"
  },
  {
    "path": "src/lib/components/Artwork.svelte",
    "chars": 1117,
    "preview": "<script lang=\"ts\">\n\timport type { IconType } from './icon/Icon.svelte'\n\timport Icon from './icon/Icon.svelte'\n\n\tinterfac"
  },
  {
    "path": "src/lib/components/BackButton.svelte",
    "chars": 648,
    "preview": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport IconButton from './IconButton.svelte'\n\n\tinterface Pro"
  },
  {
    "path": "src/lib/components/Button.svelte",
    "chars": 2551,
    "preview": "<script module lang=\"ts\">\n\timport { ripple } from '../attachments/ripple.ts'\n\timport { tooltip } from '../attachments/to"
  },
  {
    "path": "src/lib/components/FavoriteButton.svelte",
    "chars": 987,
    "preview": "<script lang=\"ts\">\n\timport IconButton from '$lib/components/IconButton.svelte'\n\timport { toggleFavoriteTrack } from '$li"
  },
  {
    "path": "src/lib/components/Header.svelte",
    "chars": 1582,
    "preview": "<script lang=\"ts\" module>\n\timport { browser } from '$app/environment'\n\texport interface HeaderProps {\n\t\tchildren?: Snipp"
  },
  {
    "path": "src/lib/components/IconButton.svelte",
    "chars": 740,
    "preview": "<script lang=\"ts\" module>\n\timport Button, { type AllowedButtonElement, type ButtonProps } from './Button.svelte'\n\timport"
  },
  {
    "path": "src/lib/components/ListDetailsLayout.svelte",
    "chars": 1441,
    "preview": "<script lang=\"ts\" module>\n\timport ScrollContainer from './ScrollContainer.svelte'\n\n\texport type LayoutMode = 'both' | 'l"
  },
  {
    "path": "src/lib/components/ListItem.svelte",
    "chars": 1070,
    "preview": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple.ts'\n</script>\n\n<script lang=\"ts\">\n\tinterface "
  },
  {
    "path": "src/lib/components/MenuButton.svelte",
    "chars": 1070,
    "preview": "<script lang=\"ts\" module>\n\timport IconButton from './IconButton.svelte'\n\timport type { IconType } from './icon/icon-path"
  },
  {
    "path": "src/lib/components/PlayerOverlay.svelte",
    "chars": 3235,
    "preview": "<script lang=\"ts\">\n\timport { formatArtists, getItemLanguage } from '$lib/helpers/utils/text.ts'\n\timport Button from './B"
  },
  {
    "path": "src/lib/components/ScrollContainer.svelte",
    "chars": 834,
    "preview": "<script lang=\"ts\" module>\n\timport { getContext, setContext } from 'svelte'\n\n\ttype ScrollTargetElement = Element | Window"
  },
  {
    "path": "src/lib/components/Select.svelte",
    "chars": 2571,
    "preview": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport Icon from './icon/Icon.svelte'\n\n\t"
  },
  {
    "path": "src/lib/components/Separator.svelte",
    "chars": 507,
    "preview": "<script lang=\"ts\">\n\tinterface Props {\n\t\tvertical?: boolean\n\t\tclass?: ClassValue\n\t}\n\n\tconst { vertical, class: className "
  },
  {
    "path": "src/lib/components/Slider.svelte",
    "chars": 5082,
    "preview": "<script lang=\"ts\">\n\timport { clamp } from '$lib/helpers/utils/clamp.ts'\n\n\tinterface Props {\n\t\tmin?: number\n\t\tmax?: numbe"
  },
  {
    "path": "src/lib/components/Spinner.svelte",
    "chars": 1084,
    "preview": "<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\tc"
  },
  {
    "path": "src/lib/components/Switch.svelte",
    "chars": 807,
    "preview": "<script lang=\"ts\">\n\tinterface Props {\n\t\tchecked: boolean\n\t}\n\n\tlet { checked = $bindable(false) }: Props = $props()\n\n\tcon"
  },
  {
    "path": "src/lib/components/Tabs.svelte",
    "chars": 1099,
    "preview": "<script lang=\"ts\" module>\n\timport { ripple } from '$lib/attachments/ripple'\n\n\tinterface Props<T> {\n\t\tselectedIndex: numb"
  },
  {
    "path": "src/lib/components/TextField.svelte",
    "chars": 1581,
    "preview": "<script lang=\"ts\">\n\tinterface TextFieldProps {\n\t\tvalue?: string\n\t\tname: string\n\t\ttype?: 'text'\n\t\tplaceholder?: string\n\t\t"
  },
  {
    "path": "src/lib/components/VirtualContainer.svelte",
    "chars": 5100,
    "preview": "<script lang=\"ts\">\n\timport {\n\t\telementScroll,\n\t\tobserveElementOffset,\n\t\tobserveElementRect,\n\t\tobserveWindowOffset,\n\t\tobs"
  },
  {
    "path": "src/lib/components/WrapTranslation.svelte",
    "chars": 843,
    "preview": "<script lang=\"ts\" generics=\"Params extends Record<string, unknown>\">\n\ttype Props = {\n\t\t[K in keyof Params]: Snippet\n\t} &"
  },
  {
    "path": "src/lib/components/animated-icons/PlayPauseIcon.svelte",
    "chars": 581,
    "preview": "<script lang=\"ts\">\n\tinterface Props {\n\t\tplaying?: boolean\n\t}\n\n\tconst { playing = false }: Props = $props()\n</script>\n\n<d"
  },
  {
    "path": "src/lib/components/animated-icons/PlayPreviousNextIcon.svelte",
    "chars": 1856,
    "preview": "<script lang=\"ts\">\n\timport { on } from 'svelte/events'\n\timport { wait } from '$lib/helpers/utils/wait.ts'\n\n\tinterface Pr"
  },
  {
    "path": "src/lib/components/dialog/CommonDialog.svelte",
    "chars": 1309,
    "preview": "<script module lang=\"ts\">\n\timport Dialog, { type DialogData, type DialogOpen, type DialogProps } from './Dialog.svelte'\n"
  },
  {
    "path": "src/lib/components/dialog/Dialog.svelte",
    "chars": 6442,
    "preview": "<script module lang=\"ts\">\n\timport type { AnimationConfig } from 'svelte/animate'\n\timport { type AnimationSequence, timel"
  },
  {
    "path": "src/lib/components/dialog/DialogFooter.svelte",
    "chars": 1008,
    "preview": "<script module lang=\"ts\">\n\timport Button, { type ButtonKind } from '../Button.svelte'\n\n\texport interface DialogButton<S "
  },
  {
    "path": "src/lib/components/global-dialogs/EqualizerDialog.svelte",
    "chars": 3140,
    "preview": "<script lang=\"ts\" module>\n\timport Button from '$lib/components/Button.svelte'\n\timport Dialog, { type DialogOpenAccessor "
  },
  {
    "path": "src/lib/components/global-dialogs/RemoveFromLibraryDialog.svelte",
    "chars": 2070,
    "preview": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport { createUIActio"
  },
  {
    "path": "src/lib/components/global-dialogs/dialogs.ts",
    "chars": 1021,
    "preview": "import type { Component } from 'svelte'\nimport type { DialogOpenAccessor } from '../dialog/Dialog.svelte'\nimport Equaliz"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/AddToPlaylistDialog.svelte",
    "chars": 1532,
    "preview": "<script lang=\"ts\" module>\n\timport Dialog, { type DialogOpenAccessor } from '$lib/components/dialog/Dialog.svelte'\n\timpor"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/AddToPlaylistDialogContent.svelte",
    "chars": 4267,
    "preview": "<script lang=\"ts\">\n\timport { SvelteMap } from 'svelte/reactivity'\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\t"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/EditPlaylistDialog.svelte",
    "chars": 1531,
    "preview": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport type { DialogOp"
  },
  {
    "path": "src/lib/components/global-dialogs/playlists/NewPlaylistDialog.svelte",
    "chars": 1228,
    "preview": "<script lang=\"ts\" module>\n\timport CommonDialog from '$lib/components/dialog/CommonDialog.svelte'\n\timport type { DialogOp"
  },
  {
    "path": "src/lib/components/icon/Icon.svelte",
    "chars": 494,
    "preview": "<script lang=\"ts\" module>\n\timport type { IconType } from './icon-paths.server.ts'\n\n\texport type { IconType } from './ico"
  },
  {
    "path": "src/lib/components/icon/icon-paths.server.ts",
    "chars": 9100,
    "preview": "// Icons taken from https://pictogrammers.com/library/mdi/\n// and then minified using https://jakearchibald.github.io/sv"
  },
  {
    "path": "src/lib/components/library-grid/LibraryGridItem.svelte",
    "chars": 4292,
    "preview": "<script lang=\"ts\" module>\n\timport { goto } from '$app/navigation'\n\timport { resolve } from '$app/paths'\n\timport { page }"
  },
  {
    "path": "src/lib/components/library-grid/LibraryGridListContainer.svelte",
    "chars": 1625,
    "preview": "<script lang=\"ts\" module>\n\timport VirtualContainer from '$lib/components/VirtualContainer.svelte'\n\timport { safeInteger "
  },
  {
    "path": "src/lib/components/menu/Menu.svelte",
    "chars": 2340,
    "preview": "<script lang=\"ts\">\n\timport { untrack } from 'svelte'\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport type {"
  },
  {
    "path": "src/lib/components/menu/MenuRenderer.svelte",
    "chars": 4077,
    "preview": "<script lang=\"ts\" module>\n\timport { createContext } from 'svelte'\n\timport { timeline } from '$lib/helpers/animations.ts'"
  },
  {
    "path": "src/lib/components/menu/positioning.ts",
    "chars": 1383,
    "preview": "import { assign } from '$lib/helpers/utils/assign.ts'\nimport type { MenuAlignment, MenuPosition } from './types.ts'\n\nexp"
  },
  {
    "path": "src/lib/components/menu/types.ts",
    "chars": 567,
    "preview": "export interface MenuPosition {\n\ttop: number\n\tleft: number\n}\n\nexport interface MenuAlignment {\n\thorizontal?: 'left' | 'r"
  },
  {
    "path": "src/lib/components/player/MainControls.svelte",
    "chars": 577,
    "preview": "<script lang=\"ts\">\n\timport PlayNextButton from './buttons/PlayNextButton.svelte'\n\timport PlayPrevButton from './buttons/"
  },
  {
    "path": "src/lib/components/player/PlayerArtwork.svelte",
    "chars": 454,
    "preview": "<script lang=\"ts\">\n\timport Artwork from '../Artwork.svelte'\n\timport type { IconType } from '../icon/Icon.svelte'\n\n\tinter"
  },
  {
    "path": "src/lib/components/player/Timeline.svelte",
    "chars": 1535,
    "preview": "<script lang=\"ts\">\n\timport { formatDuration } from '$lib/helpers/utils/format-duration.ts'\n\timport Slider from '../Slide"
  },
  {
    "path": "src/lib/components/player/VolumeSlider.svelte",
    "chars": 136,
    "preview": "<script lang=\"ts\">\n\timport Slider from '../Slider.svelte'\n\n\tconst player = usePlayer()\n</script>\n\n<Slider bind:value={pl"
  },
  {
    "path": "src/lib/components/player/buttons/ActiveIndicator.svelte",
    "chars": 245,
    "preview": "<script lang=\"ts\">\n\tconst { active }: { active: boolean } = $props()\n</script>\n\n<div\n\tclass={[\n\t\t'absolute bottom-1 size"
  },
  {
    "path": "src/lib/components/player/buttons/PlayNextButton.svelte",
    "chars": 437,
    "preview": "<script lang=\"ts\">\n\timport PlayPreviousNextIcon from '../../animated-icons/PlayPreviousNextIcon.svelte'\n\timport IconButt"
  },
  {
    "path": "src/lib/components/player/buttons/PlayPrevButton.svelte",
    "chars": 445,
    "preview": "<script lang=\"ts\">\n\timport PlayPreviousNextIcon from '../../animated-icons/PlayPreviousNextIcon.svelte'\n\timport IconButt"
  },
  {
    "path": "src/lib/components/player/buttons/PlayToggleButton.svelte",
    "chars": 392,
    "preview": "<script lang=\"ts\">\n\timport PlayPauseIcon from '$lib/components/animated-icons/PlayPauseIcon.svelte'\n\timport IconButton f"
  },
  {
    "path": "src/lib/components/player/buttons/PlayTogglePillButton.svelte",
    "chars": 385,
    "preview": "<script lang=\"ts\">\n\timport PlayPauseIcon from '../../animated-icons/PlayPauseIcon.svelte'\n\timport Button from '../../But"
  },
  {
    "path": "src/lib/components/player/buttons/PlayerFavoriteButton.svelte",
    "chars": 255,
    "preview": "<script lang=\"ts\">\n\timport FavoriteButton from '$lib/components/FavoriteButton.svelte'\n\n\tconst player = usePlayer()\n\n\tco"
  },
  {
    "path": "src/lib/components/player/buttons/RepeatButton.svelte",
    "chars": 1798,
    "preview": "<script lang=\"ts\">\n\timport { on } from 'svelte/events'\n\timport type { PlayerRepeat } from '$lib/stores/player/player.sve"
  },
  {
    "path": "src/lib/components/player/buttons/ShuffleButton.svelte",
    "chars": 511,
    "preview": "<script lang=\"ts\">\n\timport Icon from '$lib/components/icon/Icon.svelte'\n\timport IconButton from '../../IconButton.svelte"
  },
  {
    "path": "src/lib/components/playlists/PlaylistListContainer.svelte",
    "chars": 1141,
    "preview": "<script lang=\"ts\" module>\n\timport type { Playlist } from '$lib/library/types.ts'\n\timport type { IconType } from '../icon"
  },
  {
    "path": "src/lib/components/playlists/PlaylistListItem.svelte",
    "chars": 2541,
    "preview": "<script lang=\"ts\" module>\n\timport { createPlaylistQuery } from '$lib/library/get/value-queries.ts'\n\timport { FAVORITE_PL"
  },
  {
    "path": "src/lib/components/snackbar/Snackbar.svelte",
    "chars": 1693,
    "preview": "<script lang=\"ts\" module>\n\texport interface SnackbarButton {\n\t\tlabel: string\n\t\taction: () => void\n\t}\n\n\texport interface "
  },
  {
    "path": "src/lib/components/snackbar/SnackbarRenderer.svelte",
    "chars": 568,
    "preview": "<script lang=\"ts\">\n\timport { flip } from 'svelte/animate'\n\timport Snackbar from './Snackbar.svelte'\n\timport { snackbarIt"
  },
  {
    "path": "src/lib/components/snackbar/snackbar.ts",
    "chars": 1194,
    "preview": "import type { SnackbarData } from './Snackbar.svelte'\nimport { snackbarItems } from './store.svelte.ts'\n\nexport type Sna"
  },
  {
    "path": "src/lib/components/snackbar/store.svelte.ts",
    "chars": 184,
    "preview": "import type { SnackbarData } from './Snackbar.svelte'\n\n// biome-ignore lint/suspicious/noExplicitAny: this can be anythi"
  },
  {
    "path": "src/lib/components/tracks/TrackListItem.svelte",
    "chars": 7083,
    "preview": "<script lang=\"ts\">\n\timport { createManagedArtwork } from '$lib/helpers/create-managed-artwork.svelte'\n\timport { formatDu"
  },
  {
    "path": "src/lib/components/tracks/TracksListContainer.svelte",
    "chars": 5950,
    "preview": "<script lang=\"ts\" module>\n\timport { useSetOverlaySnippet } from '$lib/layout-bottom-bar.svelte.ts'\n\timport type { TrackD"
  },
  {
    "path": "src/lib/components/tracks/selection.svelte.ts",
    "chars": 1966,
    "preview": "import { SvelteSet } from 'svelte/reactivity'\n\nexport class SelectionTracker {\n\t#selectedIds: Set<number> = new SvelteSe"
  },
  {
    "path": "src/lib/components/tracks/use-track-drag-controller.svelte.ts",
    "chars": 4792,
    "preview": "import { useScrollTarget } from '../ScrollContainer.svelte'\n\nconst EDGE_THRESHOLD = 84\nconst MAX_SCROLL_STEP = 30\n\ninter"
  },
  {
    "path": "src/lib/components/tracks/use-track-menu-items.ts",
    "chars": 4705,
    "preview": "import { goto } from '$app/navigation'\nimport { resolve } from '$app/paths'\nimport { getDatabase } from '$lib/db/databas"
  },
  {
    "path": "src/lib/components/tracks/use-track-selection-controller.svelte.ts",
    "chars": 3752,
    "preview": "import { isPrimaryModifierKey } from '$lib/helpers/utils/ua.ts'\nimport { SelectionTracker } from './selection.svelte.ts'"
  },
  {
    "path": "src/lib/db/database.ts",
    "chars": 5927,
    "preview": "import type { DBSchema, IDBPDatabase, IDBPObjectStore, IndexNames, StoreNames } from 'idb'\nimport { openDB } from 'idb'\n"
  },
  {
    "path": "src/lib/db/events.ts",
    "chars": 2504,
    "preview": "import type { AppDB, AppStoreNames } from './database.ts'\n\nexport type DbBaseChange<\n\tStoreName extends AppStoreNames,\n\t"
  },
  {
    "path": "src/lib/db/lock-database.ts",
    "chars": 845,
    "preview": "import { SvelteSet } from 'svelte/reactivity'\n\nlet counter = 0\nconst pendingTasks = new SvelteSet<number>()\n/**\n * Retur"
  },
  {
    "path": "src/lib/db/query/base-query.svelte.ts",
    "chars": 4510,
    "preview": "import { assign } from '$lib/helpers/utils/assign.ts'\nimport { type DatabaseChangeDetailsList, onDatabaseChange } from '"
  },
  {
    "path": "src/lib/db/query/inline-query.svelte.ts",
    "chars": 977,
    "preview": "import { type DatabaseChangeDetailsList, onDatabaseChange } from '../events.ts'\nimport type { QueryKey } from './base-qu"
  },
  {
    "path": "src/lib/db/query/page-query.svelte.ts",
    "chars": 1744,
    "preview": "import {\n\ttype QueryBaseOptions,\n\tQueryImpl,\n\ttype QueryKey,\n\ttype QueryResult,\n\tQueryResultBox,\n\ttype QueryStateInterna"
  },
  {
    "path": "src/lib/db/query/query.ts",
    "chars": 504,
    "preview": "import {\n\tQueryImpl,\n\ttype QueryKey,\n\ttype QueryBaseOptions as QueryOptions,\n\ttype QueryResult,\n\tQueryResultBox,\n} from "
  },
  {
    "path": "src/lib/helpers/__tests__/serial-queue.test.ts",
    "chars": 2722,
    "preview": "/** biome-ignore-all lint/suspicious/useAwait: test code */\nimport { describe, expect, it, vi } from 'vitest'\nimport { S"
  },
  {
    "path": "src/lib/helpers/animations.ts",
    "chars": 1238,
    "preview": "/** @public */\nexport const animateEmpty = (\n\telement: Element,\n\toptions: number | KeyframeAnimationOptions,\n): Animatio"
  },
  {
    "path": "src/lib/helpers/audio.ts",
    "chars": 243,
    "preview": "import { isMobile, isSafari } from './utils/ua.ts'\n\n/**\n * Safari mobile does not allow changing audio volume\n * @public"
  },
  {
    "path": "src/lib/helpers/create-managed-artwork.svelte.ts",
    "chars": 1821,
    "preview": "class Artwork {\n\tstatic idCounter = 0\n\n\tstatic createRefId() {\n\t\tconst index = Artwork.idCounter\n\t\tArtwork.idCounter += "
  },
  {
    "path": "src/lib/helpers/debounced.svelte.ts",
    "chars": 458,
    "preview": "import { debounce } from './utils/debounce.ts'\n\ntype Getter<T> = () => T\n\nexport class Debounced<T> {\n\t#current: T = $st"
  },
  {
    "path": "src/lib/helpers/file-system.ts",
    "chars": 2603,
    "preview": "import { isMobile } from '$lib/helpers/utils/ua.ts'\n\nexport const isFileSystemAccessSupported: boolean = 'showDirectoryP"
  },
  {
    "path": "src/lib/helpers/focus.ts",
    "chars": 372,
    "preview": "export const doesElementHasFocus = (element: Element): boolean => element.matches(':focus')\n\nexport const findFocusedEle"
  },
  {
    "path": "src/lib/helpers/input.ts",
    "chars": 418,
    "preview": "const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'password', 'number'])\n\n/**\n * Checks if the given e"
  },
  {
    "path": "src/lib/helpers/persist.svelte.ts",
    "chars": 1158,
    "preview": "const getValue = (key: string) => {\n\ttry {\n\t\tconst valueRaw = localStorage.getItem(key)\n\t\tconst value = valueRaw === nul"
  },
  {
    "path": "src/lib/helpers/register-sw.ts",
    "chars": 1956,
    "preview": "// https://whatwebcando.today/articles/handling-service-worker-updates/\n\nconst waitForPageToLoad = () => {\n\tconst { prom"
  },
  {
    "path": "src/lib/helpers/serial-queue.ts",
    "chars": 286,
    "preview": "/** @public */\nexport class SerialQueue {\n\t#chain = Promise.resolve()\n\n\tenqueue(promiseFn: () => Promise<void>): Promise"
  },
  {
    "path": "src/lib/helpers/test-helpers.ts",
    "chars": 704,
    "preview": "import { expect } from 'vitest'\nimport { type AppStoreNames, getDatabase } from '$lib/db/database.ts'\n\n/** @public */\nex"
  },
  {
    "path": "src/lib/helpers/ui-action.ts",
    "chars": 557,
    "preview": "/**\n * Executes a UI action that shows a success message upon completion or an error message if the action fails.\n */\nex"
  },
  {
    "path": "src/lib/helpers/utils/array.ts",
    "chars": 297,
    "preview": "/** @public */\nexport const toShuffledArray = <T>(input: T[]): T[] => {\n\tconst output = [...input]\n\tfor (let i = output."
  },
  {
    "path": "src/lib/helpers/utils/assign.ts",
    "chars": 295,
    "preview": "// biome-ignore lint/suspicious/noExplicitAny: needed for inference\ntype Impossible<K extends keyof any> = {\n\t[P in K]: "
  },
  {
    "path": "src/lib/helpers/utils/clamp.ts",
    "chars": 107,
    "preview": "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",
    "chars": 482,
    "preview": "/** @public */\nexport const debounce = <Fn extends (...args: Parameters<Fn>) => ReturnType<Fn>>(\n\tfn: Fn,\n\tdelay: number"
  },
  {
    "path": "src/lib/helpers/utils/format-duration.ts",
    "chars": 389,
    "preview": "const twoDigits = (num: number) => num.toString().padStart(2, '0')\n\nexport const formatDuration = (seconds: number) => {"
  },
  {
    "path": "src/lib/helpers/utils/integers.ts",
    "chars": 138,
    "preview": "export const safeInteger = (num: number, fallback = 0): number => {\n\tif (Number.isSafeInteger(num)) {\n\t\treturn num\n\t}\n\n\t"
  },
  {
    "path": "src/lib/helpers/utils/navigate.ts",
    "chars": 106,
    "preview": "export const navigateToExternal = (url: string) => {\n\twindow.open(url, '_blank', 'noopener,noreferrer')\n}\n"
  },
  {
    "path": "src/lib/helpers/utils/text.ts",
    "chars": 1136,
    "preview": "import { type StringOrUnknownItem, UNKNOWN_ITEM } from '$lib/library/types.ts'\n\nexport const truncate = (text: string, l"
  },
  {
    "path": "src/lib/helpers/utils/throttle.ts",
    "chars": 667,
    "preview": "export const throttle = <Fn extends (...args: Parameters<Fn>) => ReturnType<Fn>>(\n\tfn: Fn,\n\tdelay: number,\n): {\n\t(...arg"
  },
  {
    "path": "src/lib/helpers/utils/ua.ts",
    "chars": 1956,
    "preview": "const isMobileRegex = /Android|iPhone|iPad|iPod/i\nconst isMacRegex = /Macintosh|Mac OS X/i\nconst isWindowsRegex = /Windo"
  },
  {
    "path": "src/lib/helpers/utils/wait.ts",
    "chars": 136,
    "preview": "/** @public */\nexport const wait = (duration: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tsetTimeout(resolve"
  },
  {
    "path": "src/lib/helpers/virtualizer.svelte.ts",
    "chars": 1303,
    "preview": "import { Virtualizer, type VirtualizerOptions } from '@tanstack/virtual-core'\n\nexport * from '@tanstack/virtual-core'\n\ne"
  },
  {
    "path": "src/lib/layout-bottom-bar.svelte.ts",
    "chars": 1216,
    "preview": "import { createContext } from 'svelte'\nimport { SvelteMap } from 'svelte/reactivity'\n\nexport interface BottomBarState {\n"
  },
  {
    "path": "src/lib/library/__tests__/play-history.test.ts",
    "chars": 2615,
    "preview": "import 'fake-indexeddb/auto'\nimport { afterEach, describe, expect, it, vi } from 'vitest'\nimport { getDatabase } from '$"
  },
  {
    "path": "src/lib/library/__tests__/playlists.test.ts",
    "chars": 11800,
    "preview": "import 'fake-indexeddb/auto'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getDataba"
  },
  {
    "path": "src/lib/library/__tests__/remove.test.ts",
    "chars": 14166,
    "preview": "import 'fake-indexeddb/auto'\nimport { beforeEach, describe, expect, it } from 'vitest'\nimport { getDatabase } from '$lib"
  },
  {
    "path": "src/lib/library/get/__tests__/value.test.ts",
    "chars": 11688,
    "preview": "import 'fake-indexeddb/auto'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getDataba"
  },
  {
    "path": "src/lib/library/get/ids-queries.ts",
    "chars": 2442,
    "preview": "import type { DatabaseChangeDetailsList } from '$lib/db/events.ts'\nimport type { DbChangeActions } from '$lib/db/query/b"
  },
  {
    "path": "src/lib/library/get/ids.ts",
    "chars": 2340,
    "preview": "import type { IDBPIndex } from 'idb'\nimport { type AppDB, type AppIndexNames, getDatabase } from '$lib/db/database.ts'\ni"
  },
  {
    "path": "src/lib/library/get/value-queries.ts",
    "chars": 1431,
    "preview": "import { createQuery, type QueryResult } from '$lib/db/query/query.ts'\nimport type { LibraryStoreName } from '../types.t"
  },
  {
    "path": "src/lib/library/get/value.ts",
    "chars": 8183,
    "preview": "import { WeakLRUCache } from 'weak-lru-cache'\nimport { type DbKey, getDatabase } from '$lib/db/database.ts'\nimport { typ"
  },
  {
    "path": "src/lib/library/play-history-actions.ts",
    "chars": 2173,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { dispatchDatabaseChangedEvent } from '$lib/db/events.ts'\nimpor"
  },
  {
    "path": "src/lib/library/playlists-actions.ts",
    "chars": 7749,
    "preview": "import type { IDBPObjectStore } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type D"
  },
  {
    "path": "src/lib/library/remove.ts",
    "chars": 5776,
    "preview": "import type { IDBPTransaction } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type D"
  },
  {
    "path": "src/lib/library/scan-actions/directories.ts",
    "chars": 5350,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { type DatabaseChangeDetails, dispatchDatabaseChangedEvent } fr"
  },
  {
    "path": "src/lib/library/scan-actions/scan-tracks.ts",
    "chars": 902,
    "preview": "import type { TracksScanOptions } from './scanner/start.ts'\n\nexport const scanTracks = async (options: TracksScanOptions"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/actions.ts",
    "chars": 7904,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { type FileEntity, getFileHandlesRecursively } from '$lib/helpe"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/import-track.ts",
    "chars": 2687,
    "preview": "import type { IDBPTransaction } from 'idb'\nimport { type AppDB, getDatabase } from '$lib/db/database.ts'\nimport { type D"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/format-artwork.ts",
    "chars": 1827,
    "preview": "import { isSafari as isSafariCheck } from '$lib/helpers/utils/ua.ts'\nimport { getPrimaryColor } from './image-primary-co"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/image-primary-color.ts",
    "chars": 5328,
    "preview": "const SHIFT = 3 // quantize 8-bit -> 5-bit\nconst BINS = 32 * 32 * 32 // 5-bit bins\nconst hueBins = 360 // hue histogram "
  },
  {
    "path": "src/lib/library/scan-actions/scanner/parse/parse-track.ts",
    "chars": 1805,
    "preview": "import { parseBuffer } from 'music-metadata'\nimport { type ParsedTrackData, UNKNOWN_ITEM } from '$lib/library/types.ts'\n"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/start.ts",
    "chars": 889,
    "preview": "import type { TracksScanMessage, TracksScanOptions, TracksScanResult } from './types.ts'\nimport TracksWorker from './wor"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/types.ts",
    "chars": 648,
    "preview": "import type { FileEntity } from '$lib/helpers/file-system'\n\nexport interface TracksScanResult {\n\t/** Count of many track"
  },
  {
    "path": "src/lib/library/scan-actions/scanner/worker.ts",
    "chars": 485,
    "preview": "/// <reference lib='WebWorker' />\n\nimport { workerAction } from './actions.ts'\nimport type { TracksScanOptions } from '."
  },
  {
    "path": "src/lib/library/tracks-queries.ts",
    "chars": 740,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/p"
  },
  {
    "path": "src/lib/library/types.ts",
    "chars": 2025,
    "preview": "import type { FileEntity } from '$lib/helpers/file-system.ts'\n\nexport type LibraryStoreName = 'tracks' | 'albums' | 'art"
  },
  {
    "path": "src/lib/menu-actions/playlists.ts",
    "chars": 697,
    "preview": "import type { MenuItem } from '$lib/components/menu/types.ts'\nimport type { Playlist } from '$lib/library/types.ts'\nimpo"
  },
  {
    "path": "src/lib/stores/dialogs/store.svelte.ts",
    "chars": 1393,
    "preview": "import type { ComponentProps } from 'svelte'\nimport type { DialogData, DialogOpenAccessor } from '$lib/components/dialog"
  },
  {
    "path": "src/lib/stores/dialogs/use-store.ts",
    "chars": 181,
    "preview": "import { createContext } from 'svelte'\nimport type { DialogsStore } from './store.svelte.ts'\n\nexport const [useDialogsSt"
  },
  {
    "path": "src/lib/stores/main/store.svelte.ts",
    "chars": 1756,
    "preview": "import { prefersReducedMotion } from 'svelte/motion'\nimport { MediaQuery } from 'svelte/reactivity'\nimport { supportsCha"
  },
  {
    "path": "src/lib/stores/main/use-store.ts",
    "chars": 169,
    "preview": "import { createContext } from 'svelte'\nimport type { MainStore } from './store.svelte.ts'\n\nexport const [useMainStore, s"
  },
  {
    "path": "src/lib/stores/player/__test__/audio-loader.test.ts",
    "chars": 6022,
    "preview": "import { afterEach, describe, expect, it, vi } from 'vitest'\n\nvi.mock('$lib/helpers/utils/ua', () => ({\n\tisAndroid: () ="
  },
  {
    "path": "src/lib/stores/player/__test__/equalizer.test.ts",
    "chars": 3350,
    "preview": "import { describe, expect, it, vi } from 'vitest'\nimport { EqualizerStore } from '$lib/stores/player/equalizer.svelte.ts"
  },
  {
    "path": "src/lib/stores/player/__test__/player.svelte.test.ts",
    "chars": 9481,
    "preview": "import 'fake-indexeddb/auto'\nimport { flushSync } from 'svelte'\nimport { afterEach, beforeEach, describe, expect, it, vi"
  },
  {
    "path": "src/lib/stores/player/__test__/queue.test.ts",
    "chars": 6410,
    "preview": "import { describe, expect, it } from 'vitest'\nimport { QueueStore } from '$lib/stores/player/queue.svelte.ts'\n\nconst tra"
  },
  {
    "path": "src/lib/stores/player/audio-loader.svelte.ts",
    "chars": 3250,
    "preview": "import { getDatabase } from '$lib/db/database'\nimport type { FileEntity } from '$lib/helpers/file-system'\nimport { isAnd"
  },
  {
    "path": "src/lib/stores/player/equalizer.svelte.ts",
    "chars": 3135,
    "preview": "import { persist } from '$lib/helpers/persist.svelte.ts'\n\nexport const EQ_BANDS = [\n\t{ frequency: 32, label: '32 Hz' },\n"
  },
  {
    "path": "src/lib/stores/player/player.svelte.ts",
    "chars": 8626,
    "preview": "import type { QueryResult } from '$lib/db/query/query.ts'\nimport { createManagedArtwork } from '$lib/helpers/create-mana"
  },
  {
    "path": "src/lib/stores/player/queue.svelte.ts",
    "chars": 4733,
    "preview": "import { onDatabaseChange } from '$lib/db/events.ts'\nimport { toShuffledArray } from '$lib/helpers/utils/array.ts'\n\nexpo"
  },
  {
    "path": "src/lib/stores/player/use-store.ts",
    "chars": 173,
    "preview": "import { createContext } from 'svelte'\nimport type { PlayerStore } from './player.svelte.ts'\n\nexport const [usePlayer, s"
  },
  {
    "path": "src/lib/theme.ts",
    "chars": 4416,
    "preview": "import {\n\targbFromHex,\n\tCam16,\n\tHctSolver,\n\thexFromArgb,\n\t// biome-ignore lint/style/noRestrictedImports: Main module fo"
  },
  {
    "path": "src/lib/view-transitions.svelte.ts",
    "chars": 3000,
    "preview": "import type { AfterNavigate, OnNavigate } from '@sveltejs/kit'\nimport { browser } from '$app/environment'\nimport { onNav"
  },
  {
    "path": "src/params/libraryEntities.ts",
    "chars": 303,
    "preview": "const libraryEntitiesSlugs = ['tracks', 'albums', 'artists', 'playlists'] as const\ntype LibraryEntitiesSlug = (typeof li"
  },
  {
    "path": "src/routes/(app)/(plain)/+layout.svelte",
    "chars": 315,
    "preview": "<script lang=\"ts\">\n\timport { page } from '$app/state'\n\timport Header from '$lib/components/Header.svelte'\n\n\tconst { chil"
  },
  {
    "path": "src/routes/(app)/(plain)/about/+page.svelte",
    "chars": 1456,
    "preview": "<script lang=\"ts\">\n\timport { ripple } from '$lib/attachments/ripple'\n\timport Icon, { type IconType } from '$lib/componen"
  },
  {
    "path": "src/routes/(app)/(plain)/about/+page.ts",
    "chars": 125,
    "preview": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): { title: string } => ({\n\ttitle: m.about()"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/+page.svelte",
    "chars": 8511,
    "preview": "<script lang=\"ts\">\n\timport { tooltip } from '$lib/attachments/tooltip.ts'\n\timport Button from '$lib/components/Button.sv"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/+page.ts",
    "chars": 1802,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/p"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/DirectoriesList.svelte",
    "chars": 5671,
    "preview": "<script lang=\"ts\">\n\timport { ripple } from '$lib/attachments/ripple.ts'\n\timport { tooltip } from '$lib/attachments/toolt"
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/InstallAppBanner.svelte",
    "chars": 1032,
    "preview": "<script lang=\"ts\">\n\timport Button from '$lib/components/Button.svelte'\n\timport { isMobile } from '$lib/helpers/utils/ua."
  },
  {
    "path": "src/routes/(app)/(plain)/settings/components/MissingFsApiBanner.svelte",
    "chars": 568,
    "preview": "<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"
  },
  {
    "path": "src/routes/(app)/+layout.svelte",
    "chars": 6684,
    "preview": "<script lang=\"ts\">\n\timport { browser } from '$app/environment'\n\timport { navigating, page } from '$app/state'\n\timport Bu"
  },
  {
    "path": "src/routes/(app)/layout/app-install-prompt.ts",
    "chars": 291,
    "preview": "export const setupAppInstallPromptListeners = () => {\n\tconst main = useMainStore()\n\n\twindow.addEventListener('appinstall"
  },
  {
    "path": "src/routes/(app)/layout/setup-directories-permission-prompt.svelte.ts",
    "chars": 1771,
    "preview": "import { getDatabase } from '$lib/db/database'\n\nexport interface DirectoryNeedingPermission {\n\tname: string\n\taction: () "
  },
  {
    "path": "src/routes/(app)/layout/setup-theme.svelte.ts",
    "chars": 1701,
    "preview": "import { isSafari } from '$lib/helpers/utils/ua'\n\nconst updateThemeMetaElement = (element: Element) => {\n\t// Background "
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+layout.svelte",
    "chars": 6330,
    "preview": "<script lang=\"ts\">\n\timport type { Snapshot } from '@sveltejs/kit'\n\timport { goto } from '$app/navigation'\n\timport { page"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+layout.ts",
    "chars": 3738,
    "preview": "import { redirect } from '@sveltejs/kit'\nimport { innerWidth } from 'svelte/reactivity/window'\nimport type { RouteId } f"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/+page.svelte",
    "chars": 1072,
    "preview": "<script>\n\timport Icon from '$lib/components/icon/Icon.svelte'\n</script>\n\n<div class=\"m-auto flex flex-col items-center j"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/Search.svelte",
    "chars": 2534,
    "preview": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport IconButton from '$lib/components/IconButton.svelte'\n\t"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.svelte",
    "chars": 5519,
    "preview": "<script lang=\"ts\">\n\timport { MediaQuery } from 'svelte/reactivity'\n\timport Artwork from '$lib/components/Artwork.svelte'"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/[uuid]/+page.ts",
    "chars": 4322,
    "preview": "import { error, redirect } from '@sveltejs/kit'\nimport { goto } from '$app/navigation'\nimport { type DbValue, getDatabas"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/config.ts",
    "chars": 2512,
    "preview": "import type { DbValue } from '$lib/db/database.ts'\nimport type { LibraryItemSortKey } from '$lib/library/get/ids.ts'\nimp"
  },
  {
    "path": "src/routes/(app)/library/[[slug=libraryEntities]]/store.svelte.ts",
    "chars": 724,
    "preview": "import { persist } from '$lib/helpers/persist.svelte.ts'\nimport type { LibraryItemSortKey, SortOrder } from '$lib/librar"
  },
  {
    "path": "src/routes/(app)/player/+layout.svelte",
    "chars": 11178,
    "preview": "<script lang=\"ts\">\n\timport { goto } from '$app/navigation'\n\timport { page } from '$app/state'\n\timport BackButton from '$"
  },
  {
    "path": "src/routes/(app)/player/+layout.ts",
    "chars": 1721,
    "preview": "import { getDatabase } from '$lib/db/database.ts'\nimport { createPageQuery, type PageQueryResult } from '$lib/db/query/p"
  },
  {
    "path": "src/routes/(app)/player/+page.ts",
    "chars": 90,
    "preview": "import type { PageLoad } from './$types.ts'\n\nexport const load: PageLoad = (): void => {}\n"
  }
]

// ... and 30 more files (download for full content)

About this extraction

This page contains the full source code of the minht11/local-music-pwa GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 230 files (547.6 KB), approximately 167.9k tokens, and a symbol index with 297 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!